From 8a0fd4d03db34242c818d9a0ec1fe82bc1ed5383 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 10:51:04 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(729-1):=20=EC=8B=A4=EB=AC=B4=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20=E2=86=92=20=EA=B8=B0?= =?UTF-8?q?=EC=B4=88=20=C2=B7=20SK=20=C2=B7=20=EA=B3=A0=EB=A6=84=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0=20=E2=80=94=20=EB=B8=94=EB=A1=9D=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=ED=8C=90=EC=A0=95=20=E2=86=92=20=EC=97=91=EC=85=80?= =?UTF-8?q?=20=ED=92=88=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=EB=8D=AE=EA=B0=9C?= =?UTF-8?q?=20=C2=B7=20=EB=8D=94=ED=95=9C=EC=A4=84=20=C2=B7=20=EB=BA=80?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20=EB=B3=84=EC=B9=AD=20=EC=A0=9C=EC=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Hopyo_SkPatch: 판정 맞음 = 기초(바탕 = 1위 기초 · 덮개 없음) · 다른 점 = 바탕 기초 + 인력 덮개(qty · 배율) · 엑셀만 직종 · 장비(GC000267 · GC000268 기계 옵션 찾기) 더한줄 · 우리만 줄 뺀줄 · 블록 안 자재 덮개 · 옮기지 못한 차이 = 본뜬.못옮김 · 후보 = 고름 대기 - M02_Hopyo_Sangeun: 바탕 없는 SK + 고정 한 줄 걷음 · 블록마다 기초 · SK · 고름 대기 · 고른 기초(바탕 {번: 로직}) · 별칭 장부 짝 = 기초까지만 · 맞춤 셈 전 409 - 여덟 파일 206 블록 = 기초 82 · SK 40 · 고름 대기 84 · SK 품 어긋남 0 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Sangeun.py | 200 +++++++++++----- M02_MasterTemplete/M02_Hopyo_SkPatch.py | 294 ++++++++++++++++++++++++ 2 files changed, 437 insertions(+), 57 deletions(-) create mode 100644 M02_MasterTemplete/M02_Hopyo_SkPatch.py diff --git a/M02_MasterTemplete/M02_Hopyo_Sangeun.py b/M02_MasterTemplete/M02_Hopyo_Sangeun.py index af0c86cda..9d071aecf 100644 --- a/M02_MasterTemplete/M02_Hopyo_Sangeun.py +++ b/M02_MasterTemplete/M02_Hopyo_Sangeun.py @@ -1,11 +1,16 @@ -"""M02 일위대가 불러오기 → 단가산출(PLAN 718-1 · 요구서 내역12 4장 22) — -산근(단산) 블록 = 바탕 없는 단가산출. +"""M02 일위대가 불러오기 → 단가산출(PLAN 718-1 · 729-1 · 요구서 내역19 1장 7 · 3장 6) — +산근(단산) 블록마다 매칭 → 비교 → 기초 · SK · 고름 대기. -블록마다 SK 하나 — 그 블록을 부르는 첫 엑셀 줄(「단산N참조」)의 1 단위 단가 -(노무비 · 재료비 · 경비)를 고정 더한줄 하나로(`M02_Hopyo_Fixed` 꼴 · 엑셀 숫자 그대로 · -서버가 파일에서 읽음) · 이름 = 블록 이름(없으면 줄 품명) · 단위 = 줄 단위(없으면 블록 · 「식」) · -구분 「실무자료」 · 상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 단산N」, 파일, 원본, -확인} · 같은 본뜬이 이 층에 있으면 그대로(겹침 없음) · 만드는 층 = 부르는 층(마법사에서 고른 층). +블록마다 그 블록을 부르는 첫 엑셀 줄(「단산N참조」)의 맞춤셈(`M02_Hopyo_Fit` 캐시 · 없으면 그 블록만 +셈) → `M02_Hopyo_SkPatch.plan`: + 기초 = SK 바탕 = 1위 기초 · 입력값 · 덮개 없음 · SK = 바탕 기초 + 덮개 · 더한줄 · 뺀줄(엑셀 품 그대로) · + 고름 대기 = SK 안 만듦(바탕 없는 SK 로 몰래 채우지 않음) · 후보 다섯 → 사용자가 고른 기초 `바탕 {번: {로직, + 입력값?}}` 로 다시 부르면 그 기초에 견줘 SK(이미 있으면 그 키에 덮음). +SK = 이름 블록 이름(없으면 줄 품명) · 단위 = 줄 단위(없으면 블록 · 「식」) · 구분 「실무자료」 · 상세구분 = 파일 · +본뜬 = {층 「실무자료」, 키 「HP-###### 단산N」, 파일, 원본, 확인, 갈래, 엑셀품, 못옮김} · 같은 본뜬(키 · 원본)이 +이 층에 있으면 그대로(겹침 없음) · 만드는 층 = 부르는 층. +별칭 제안 = 기초 · SK 만든 블록의 엑셀 줄 → 이 층 별칭 장부 짝 = 바탕 기초까지만(차이 SK 는 이 층 문서 · +요구서 3장 6) · 장부에 이미 짝 · 갈래가 있는 줄은 그대로 · 승인 길 = M01 별칭 장부(716). """ from __future__ import annotations @@ -15,10 +20,13 @@ from pathlib import Path from typing import Any from common_util.common_util_time import utc_now_text -from M02_MasterTemplete import M02_Hopyo_Fixed as hfix +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Fit as fit +from M02_MasterTemplete import M02_Hopyo_Ledger as ledger +from M02_MasterTemplete import M02_Hopyo_SkPatch as skpatch from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store -from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store ORIGIN = "실무자료" KIND = "sangeun" @@ -28,76 +36,154 @@ def mark(key: str, no: int) -> str: return f"{key} 단산{no}" -def made(folder: str | Path, key: str | None = None) -> dict[str, str]: - """이 층 실무자료 단가산출 `{본뜬 키: SK 키}`(`key` = 그 HP 것만).""" +def made( + folder: str | Path, key: str | None = None, source: refs.Here | None = None +) -> dict[str, str]: + """이 층 실무자료 단가산출 `{본뜬 키: SK 키}`(`key` = 그 HP 것만 · `source` = 파일 층으로 거름).""" out = {} for path in sorted((Path(folder) / KIND).glob("SK-*.json")): try: got = json.loads(path.read_bytes()).get("본뜬") except (OSError, ValueError, AttributeError): continue - if isinstance(got, dict) and got.get("층") == ORIGIN: - if key is None or str(got.get("키") or "").startswith(f"{key} "): - out[str(got.get("키"))] = path.stem + if not isinstance(got, dict) or got.get("층") != ORIGIN: + continue + if key is not None and not str(got.get("키") or "").startswith(f"{key} "): + continue + if source is None or not got.get("원본") or tuple(got["원본"]) == tuple(source): + out[str(got.get("키"))] = path.stem return out -def _doc(line: dict, block: dict, no: int, hp: dict, stamp: str, who: Any) -> dict[str, Any]: +def calls(hp: dict[str, Any]) -> tuple[dict[int, dict], dict[int, dict]]: + """(블록 {번: 블록}, 부르는 첫 줄 {번: 줄 + `_호표`}).""" + blocks = {int(b["번"]): b for b in hp.get("단산") or [] if b.get("번") is not None} + first: dict[int, dict] = {} + for h in hp.get("호표") or []: + for line in h.get("줄") or []: + ref = line.get("참조") or {} + no = int(ref.get("번") or 0) if ref.get("종류") == "단산" else 0 + if no in blocks and no not in first: + first[no] = {**line, "_호표": h.get("번")} + return blocks, first + + +def _bad(text: str, status: int = 400) -> store.StoreError: + return store.StoreError(status, text) + + +def _picks(raw: Any) -> dict[int, dict[str, Any]]: + """사용자가 고른 기초 `{번: {로직, 입력값?}}` 재검사 — 돈 로직만 · 입력 이름 = 그 로직 입력.""" + if not raw: + return {} + if not isinstance(raw, dict) or len(raw) > 500: + raise _bad("바탕은 {블록 번: {로직, 입력값}}") + logics = ledger._known()[0] + out = {} + for no, one in raw.items(): + if not isinstance(one, dict) or str(one.get("로직") or "") not in logics: + raise _bad(f"단산{no} · 돈 로직이 아님 「{(one or {}).get('로직')}」") + given = one.get("입력값") + if given is not None: + bad = set(given) - ledger.input_names(one["로직"]) if isinstance(given, dict) else {1} + if bad: + raise _bad(f"단산{no} · 모르는 입력 {sorted(map(str, bad))}") + out[int(no)] = {"로직": one["로직"], **({"입력값": given} if given is not None else {})} + return out + + +def _doc(line: dict, block: dict, no: int, hp: dict, plan: dict, sure: dict) -> dict[str, Any]: name = str(block.get("이름") or line.get("이름") or "").strip() or f"단산{no}" unit = str(line.get("단위") or block.get("단위") or "").strip() or "식" - spec = str(line.get("규격") or "").strip() - price = {k: hfix._plain(v) for k, v in hfix._parts(line.get("단가")).items()} - fx = { - "이름": name, - "규격": spec, - "단위": unit, - "단가": price, - "이음": dict.fromkeys(fixed.LINKS), - "출처": {"파일": hp.get("파일"), "호표": line.get("_호표"), "줄": int(line.get("차례", 0))}, - } + keep = {k: plan[k] for k in ("갈래", "엑셀품", "못옮김")} return { "종류": "단가산출", "이름": name, "단위": unit, "구분": ORIGIN, "상세구분": str(hp.get("파일") or ""), - "본뜬": { - "층": ORIGIN, - "키": mark(hp["키"], no), - "파일": hp.get("파일"), - "원본": list(refs.here()), - "확인": {"누가": who, "때": stamp}, - }, - "바탕": None, - "입력값": {}, + "본뜬": {"층": ORIGIN, "키": mark(hp["키"], no), "파일": hp.get("파일"), **sure, **keep}, + "바탕": plan["바탕"], + "입력값": plan["입력값"], "열린입력": [], "고름": {}, - "자재표시": {}, - "덮개": {}, - "더한줄": [{"고정": fx, "이름": name, "단위": unit, "수량": 1}], - "뺀줄": [], + **{k: plan[k] for k in ("자재표시", "덮개", "더한줄", "뺀줄")}, } -def build(folder: Path, key: str, who: Any = None) -> dict[str, Any]: - """[단가산출 만들기] — 산근 블록마다 바탕 없는 단가산출 → - `{블록, 만듦 [{번, 이름, 키}], 그대로}`.""" - hp = hopyo_store.read(folder, key) - blocks = {int(b["번"]): b for b in hp.get("단산") or [] if b.get("번") is not None} - calls: dict[int, dict] = {} - for h in hp.get("호표") or []: - for line in h.get("줄") or []: - ref = line.get("참조") or {} - no = int(ref.get("번") or 0) if ref.get("종류") == "단산" else 0 - if no in blocks and no not in calls: - calls[no] = {**line, "_호표": h.get("번")} - have, stamp = made(folder, key), utc_now_text() - out: dict[str, Any] = {"블록": len(calls), "만듦": [], "그대로": 0} - for no in sorted(calls): - if mark(key, no) in have: +def _alias(line: dict, plan: dict) -> dict[str, Any]: + origin = {"품명": line.get("이름"), "규격": line.get("규격"), "단위": line.get("단위")} + clauses = (line.get("근거") or {}).get("출처") or [] + pair = {"로직": plan["바탕"]["로직"], "입력값": plan["입력값"], "배": 1, "몫": None} + return { + "원래": {k: str(v or "") for k, v in origin.items()}, + "갈래": "짝", + "짝": [pair], + "엑셀조항": clauses if isinstance(clauses, list) else [], + } + + +class Maker: + """한 번 만들기 — 파일 · 맞춤셈 · 이 층 SK 를 한 번 읽음(`source` = 파일 층 · 쓰기 = `store.root()`).""" + + def __init__(self, source: Path, key: str, who: Any = None) -> None: + self.source, self.key, self.who = Path(source), key, who + self.hp = hopyo_store.read(source, key) + self.blocks, self.first = calls(self.hp) + got = fit.result(source, key) or {} + self.cache, self.done = got.get("블록") or {}, bool(got.get("끝")) + self.here = refs.where(source) + self.whole = m01.loaded()[1] + self.sure = {"원본": list(self.here), "확인": {"누가": who, "때": utc_now_text()}} + + def plan(self, no: int, pick: dict | None = None) -> dict[str, Any]: + line, block = self.first[no], self.blocks[no] + entry = self.cache.get(fit.unit_key(line)) + if entry is None and not pick: + entry = fit.compute(block, line) + return skpatch.plan(entry, block, line, self.whole, pick) + + def write(self, no: int, plan: dict, have: str | None) -> str: + doc = _doc(self.first[no], self.blocks[no], no, self.hp, plan, self.sure) + if have: + raw = (store.root() / KIND / f"{have}.json").read_bytes() + store.write(KIND, have, store.version_of(raw), {**doc, "키": have}) + return have + return refs.create(KIND, doc)["이름"] + + +def build(folder: Path, key: str, who: Any = None, picks: Any = None) -> dict[str, Any]: + """[단가산출 만들기] — 산근 블록마다 기초 · SK · 고름 대기 → `{블록, 만듦 [{번, 이름, 키, 갈래}], + 그대로, 기초, SK, 고름대기 [{번, 이름, 단위, 까닭, 후보}], 별칭}` · `picks` = 고른 기초.""" + chosen = _picks(picks) + maker = Maker(folder, key, who) + bad = sorted(set(chosen) - set(maker.first)) + if bad: + raise _bad(f"없는 블록 · 부르는 줄 없음 「단산{bad[0]}」", 404) + if not maker.done and set(maker.first) - set(chosen): + fit.ensure(folder, key) + raise _bad("맞춤 셈 중 — 끝나면 다시 누름", 409) + have = made(folder, key, maker.here) + out: dict[str, Any] = {"블록": len(maker.first), "만듦": [], "그대로": 0, "고름대기": []} + out.update({skpatch.BASE: 0, skpatch.SK: 0}) + books, alias = ledger.books(folder), {} + for no in sorted(maker.first): + old = have.get(mark(key, no)) + if old and no not in chosen: out["그대로"] += 1 continue - doc = _doc(calls[no], blocks[no], no, hp, stamp, who) - got = refs.create(KIND, doc) - out["만듦"].append({"번": no, "이름": doc["이름"], "키": got["이름"]}) + plan = maker.plan(no, chosen.get(no)) + line = maker.first[no] + if plan["갈래"] == skpatch.WAIT: + head = {"번": no, "이름": maker.blocks[no].get("이름") or line.get("이름")} + out["고름대기"].append({**head, "단위": line.get("단위"), **plan}) + continue + got = maker.write(no, plan, old) + out[plan["갈래"]] += 1 + name = maker.blocks[no].get("이름") or line.get("이름") + out["만듦"].append({"번": no, "이름": name, "키": got, "갈래": plan["갈래"]}) + found = ledger.recall(line, folder, books) + if no in chosen or found is None or found.get("규격다름"): + alias[ledger.key(line)] = _alias(line, plan) + out["별칭"] = ledger.write(folder, alias, who, maker.hp.get("파일") or None) return out diff --git a/M02_MasterTemplete/M02_Hopyo_SkPatch.py b/M02_MasterTemplete/M02_Hopyo_SkPatch.py new file mode 100644 index 000000000..04be3e43f --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_SkPatch.py @@ -0,0 +1,294 @@ +"""M02 실무 단가산출(산근 블록) → 단가산출(SK) 꼴(PLAN 729-1 · 요구서 내역19 1장 7 · 3장 6 · +분석 19-B D-1). + +블록 맞춤셈 한 칸(`M02_Hopyo_Verdict.block` 꼴 · 캐시 `M02_Hopyo_Fit.result`) → 갈래 셋: + 기초 = 판정 맞음(또는 견줘 차이 없음) — 바탕 = 1위 기초 · 입력값 = 옵션 거꾸로 찾은 값 · 덮개 없음 + SK = 다른 점 — 바탕 = 1위 기초(조합 둘째부터 = 더한줄 로직 · 수량 = 몫) + 차이 = 엑셀 품 그대로 + 인력 = 직종마다 덮개 qty(줄 하나) · 배율(여럿) · 엑셀만 직종 = 더한줄 인력 · 우리만 = 뺀줄 + 장비 = 시간 같은 같은 갈래 줄은 그대로 · 같은 갈래 남은 줄 = 덮개 qty · 엑셀만 = 더한줄 + 기계경비 로직(굴착기 GC000267 · 그 밖 GC000268 · 기계 = 옵션 거꾸로 찾기) · 남은 우리 = 뺀줄 + 자재 = 같은 이름 우리 줄 수량 덮개 · 옮기지 못한 차이 = `못옮김`(버리지 않고 본뜬에 적음) + 고름 대기 = 후보 · 숫자 없음 — SK 안 만듦 · 후보 다섯(사용자가 고른 기초 `pick` = 견줘 SK · 기초). +cid = `kind:키#n`(첫 기초 바탕 줄만 · `M02_Sangeun_Doc.line_ids`) · 더한줄 · 덮개 qty = 바탕 행 몫 +(1 단위 품 × 바탕 결과단위 앞 수 · 셈이 ÷ 배) · 더한줄 로직 수량 = 몫 × 바탕 배 ÷ 그 기초 배. +품 견줌 = 우리 셈(`M02_Sangeun_Calc.run` 줄 수량 ÷ 배) · 같음 = `Option.near`(0.5 % + 엑셀 자릿수 반올림). +우리만 자재 줄은 그대로(판정 견줌도 안 봄 · 엑셀은 자재를 일위대가 줄로 따로 적는 일이 많음). +서버 단독 · 읽기만(쓰기 = `M02_Hopyo_Sangeun`). +""" + +from __future__ import annotations + +import re +from decimal import Decimal +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Combine as combine +from M02_MasterTemplete import M02_Hopyo_Finger as finger +from M02_MasterTemplete import M02_Hopyo_Fit as fit +from M02_MasterTemplete import M02_Hopyo_Option as option +from M02_MasterTemplete import M02_Hopyo_Ours as ours +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from M02_MasterTemplete.M02_Hopyo_Words import mtypes, specnums + +BASE, SK, WAIT = "기초", "SK", "고름 대기" +KEYS = ("덮개", "자재표시", "더한줄", "뺀줄", "못옮김") +TINY = 1e-9 +_ADD = re.compile(r"^add:#(\d+)$") + + +def _near(a: float, b: float) -> bool: + return abs(a - b) <= TINY or option.near(a, b, fit.TOL[0], option.ru(a)) + + +def _num(x: float) -> int | float: + got = Decimal(repr(float(x))).normalize() + return int(got) if got == got.to_integral_value() else float(got) + + +# ── 우리 셈 → 1 단위 품 ────────────────────────────────────────────── +def _types(sub: str, item: dict, args: str | None, given: dict, auto: dict) -> tuple[set, set]: + """기계경비 줄 갈래 · 규격 — `M02_Hopyo_Ours._engine` 과 같은 읽기(중간값 없이).""" + ts = set(ours.FIXED_MACH.get(sub, set())) | mtypes(item.get("이름")) + sp = set(specnums(item.get("이름"))) + for v in ours._args(args).values(): + lit = v.strip("'\"") + if not ours.EQ_CODE.match(lit): + lit = str(given.get(lit) or auto.get(lit) or "") + if ours.EQ_CODE.match(lit): + a, b = ours.eq_of(lit) + ts, sp = ts | a, sp | b + return ts, sp + + +def parts(doc: dict, whole: Any) -> list[dict[str, Any]]: + """SK 문서 → 줄마다 `{cid, 갈래 인력|장비|자재|기초|그밖, 이름 · 종류 · 규격, 품(1 단위)}` · + 더한줄 기초 = 그 기초 1 단위 품 × 줄 몫(`L` · `X`).""" + got = calc.resolve(doc, whole) + items = got["줄"]["호표"] + ran = calc.run(doc, whole) + times = Decimal(str(ran["배"])) + labor = ours._labor_names() + auto = {} + if (doc.get("바탕") or {}).get("로직"): + auto = option.m01.mau.values(whole, whole.logic(doc["바탕"]["로직"])) + qty = {x["cid"]: x.get("수량") for x in ran.get("줄") or [] if x.get("cid")} + out = [] + for item, cid in zip(items, got["정체"]): # 별도계상 재료 = 셈 줄에서 빠짐 → 품 None + raw = qty.get(cid) + one: dict[str, Any] = {"cid": cid, "갈래": "그밖"} + one["품"] = None if raw is None else float(Decimal(str(raw)) / times) + kind, found = item.get("종류"), ours._SUB.match(str(item.get("요소") or "")) + if kind == "인력": + one.update(갈래="인력", 이름=labor.get(str(item.get("요소")), str(item.get("이름")))) + elif kind == "재료": + one.update(갈래="자재", 이름=str(item.get("이름") or "")) + elif kind == "로직" and found: + sub = found.group(1) + if ours.is_mach_logic(whole.logic(sub)): + ts, sp = _types(sub, item, found.group(2), got["입력값"], auto) + one.update(갈래="장비", 종류=ts, 규격=sp) + elif hit := _ADD.match(cid): + given = (doc.get("더한줄") or [])[int(hit[1])].get("입력값") or {} + v = ours.run(sub, given) or {"L": {}, "X": []} + k = (one["품"] or 0.0) * ours.times_of(whole.logic(sub)) + one.update(갈래="기초", 로직=sub, L={t: q * k for t, q in v["L"].items()}) + one["X"] = [(set(a), set(b), q * k) for a, b, q in v["X"]] + out.append(one) + return out + + +def measure(doc: dict, whole: Any) -> dict[str, Any]: + """SK 1 단위 품 — `{인력 {직종: 품}, 장비 [{갈래, 시간}], 자재 {이름: 수량}}`(시험 · 견줌).""" + labor: dict[str, float] = {} + machines, mats = [], {} + for one in parts(doc, whole): + if one["갈래"] == "인력" and one["품"]: + labor[one["이름"]] = labor.get(one["이름"], 0.0) + one["품"] + elif one["갈래"] == "장비" and one["품"]: + machines.append({"갈래": sorted(one["종류"]), "시간": one["품"]}) + elif one["갈래"] == "자재" and one["품"]: + mats[one["이름"]] = mats.get(one["이름"], 0.0) + one["품"] + elif one["갈래"] == "기초": + for t, q in one["L"].items(): + labor[t] = labor.get(t, 0.0) + q + machines += [{"갈래": sorted(a), "시간": q} for a, _, q in one["X"] if q] + return {"인력": labor, "장비": machines, "자재": mats} + + +# ── 차이 → 덮개 · 더한줄 · 뺀줄 ────────────────────────────────────── +class _Plan: + def __init__(self, doc: dict, whole: Any) -> None: + self.doc, self.whole = doc, whole + self.tb = float(calc.times_of(whole.logic(doc["바탕"]["로직"]))) + self.lines = parts(doc, whole) + self.lost: list[str] = [] + self.lb = {name: key for key, name in ours._labor_names().items()} + + def cover(self, cid: str, **one: float) -> None: + self.doc["덮개"][cid] = {k: _num(v) for k, v in one.items()} + + def drop(self, cid: str) -> None: + if cid not in self.doc["뺀줄"]: + self.doc["뺀줄"].append(cid) + + def add(self, one: dict[str, Any]) -> None: + self.doc["더한줄"].append(one) + + def labor(self, excel: dict[str, float]) -> None: + mine = [x for x in self.lines if x["갈래"] == "인력" and not x["cid"].startswith("add:")] + mine = [{**x, "품": x["품"] or 0.0} for x in mine] + extra: dict[str, float] = {} + for x in self.lines: + for t, q in (x.get("L") or {}).items(): + extra[t] = extra.get(t, 0.0) + q + for t in sorted(set(excel) | {x["이름"] for x in mine if x["품"]} | set(extra)): + a, rows = excel.get(t, 0.0), [x for x in mine if x["이름"] == t] + b, f = sum(x["품"] for x in rows), extra.get(t, 0.0) + if a and _near(a, b + f): + continue + want = a - f + if want <= TINY: + for x in rows: + self.drop(x["cid"]) + if want < -TINY: + self.lost.append(f"{t} 엑셀 {a:.6g} · 더한 기초만 {f:.6g}") + elif not rows: + if t in self.lb: + unit = "인" + self.add({"종류": "인력", "요소": self.lb[t], "이름": t, "단위": unit, + "수량": _num(want * self.tb)}) # fmt: skip + else: + self.lost.append(f"{t} 엑셀 {a:.6g} — 우리 인력에 없음") + elif len(rows) == 1 or not b: + self.cover(rows[0]["cid"], qty=want * self.tb) + for x in rows[1:]: + self.drop(x["cid"]) + else: + for x in rows: + self.cover(x["cid"], 배율=want / b) + + def machines(self, excel: list[dict[str, Any]], line: dict[str, Any]) -> None: + mine = [x for x in self.lines if x["갈래"] == "장비" and x["품"]] + fixed = [(a, q) for x in self.lines for a, _, q in x.get("X") or [] if q] + used, rest = set(), [] + for m in excel: + kinds, h = set(m["갈래"]), m["시간"] + hit = next((x for x in mine if x["cid"] not in used and x["종류"] & kinds + and _near(h, x["품"])), None) # fmt: skip + if hit is not None: + used.add(hit["cid"]) + continue + at = next((i for i, (a, q) in enumerate(fixed) if a & kinds and _near(h, q)), None) + if at is not None: + fixed.pop(at) + continue + rest.append(m) + for m in rest: + kinds, sp = set(m["갈래"]), set(m.get("규격") or []) + same = [x for x in mine if x["cid"] not in used and x["종류"] & kinds] + same.sort(key=lambda x: not (sp and sp & x["규격"])) + if same: + used.add(same[0]["cid"]) + self.cover(same[0]["cid"], qty=m["시간"] * self.tb) + else: + self._add_machine(m, line) + for x in mine: + if x["cid"] not in used: + self.drop(x["cid"]) + self.lost += [f"우리만 장비 {'·'.join(sorted(a))} — 더한 기초 안" for a, _ in fixed] + + def _add_machine(self, m: dict[str, Any], line: dict[str, Any]) -> None: + """엑셀만 장비 → 기계경비 로직 줄(기계 = 그 갈래 · 규격 옵션 거꾸로 찾기).""" + fp = finger.empty(line) + fp["장비"] = [{**m, "시간": 1.0, "밑시간": 1.0, "율": []}] + kinds = set(m["갈래"]) + first = next((c for k, c in combine.MACHINE.items() if k in kinds), combine.MACHINE_ANY) + for code in [first, *sorted(combine.MACHINE_SET - {first})]: + got = option.find(code, fp, []) + if got["수준"] <= 1: + name = " ".join([*m["갈래"], *(f"{x:g}" for x in m.get("규격") or [])]) + self.add({"종류": "로직", "로직": code, "입력값": got["입력"], "이름": name, + "단위": "시간", "수량": _num(m["시간"] * self.tb)}) # fmt: skip + return + self.lost.append(f"엑셀만 장비 {'·'.join(m['갈래'])} {m['시간']:.6g} 시간 — 기계 못 찾음") + + def materials(self, excel: list[dict[str, Any]]) -> None: + mine = [x for x in self.lines if x["갈래"] == "자재" and not x["cid"].startswith("add:")] + for m in excel: + words = option.mat_words(m["이름"]) + rows = [x for x in mine if option._named(words, x["이름"])] + if rows and all(x["품"] is None for x in rows): # 우리 = 별도계상 · 엑셀 = 블록 안 + self.doc["자재표시"][rows[0]["cid"]] = {"별도계상": "직접계산"} + self.cover(rows[0]["cid"], qty=m["품"] * self.tb) + continue + rows = [x for x in rows if x["품"] is not None] + if rows and _near(m["품"], sum(x["품"] for x in rows)): + continue + if rows: + self.cover(rows[0]["cid"], qty=m["품"] * self.tb) + for x in rows[1:]: + self.drop(x["cid"]) + else: + self.lost.append(f"엑셀만 자재 {m['이름']} {m['품']:.6g}") + + +def _extras(entry: dict, code: str, whole: Any, tb: float) -> list[dict[str, Any]]: + """조합 둘째부터(체크된 로직) = 더한줄 로직 — 수량 = 몫 × 바탕 배 ÷ 그 기초 배.""" + out = [] + for c in entry.get("조합") or []: + if not c.get("체크") or c["로직"] == code: + continue + given = ((entry.get("옵션") or {}).get(c["로직"]) or {}).get("입력") or {} + times = ours.times_of(whole.logic(c["로직"])) + qty = float(c.get("몫") or 1) * tb / times + out.append({"종류": "로직", "로직": c["로직"], "입력값": given, "수량": _num(qty)}) + return out + + +def _wait(entry: dict) -> dict[str, Any]: + keep = ("로직", "이름", "단위", "판정", "글") + cands = [{k: c.get(k) for k in keep} for c in (entry.get("후보") or [])[: fit.TOP_OPT]] + why = (entry.get("판정") or {}).get("글") or "후보 없음" + return {"갈래": WAIT, "까닭": f"맞는 기초 없음 — {why}", "후보": cands} + + +def plan( + entry: dict | None, + block: dict | None, + line: dict[str, Any], + whole: Any, + pick: dict | None = None, +) -> dict[str, Any]: + """블록 한 칸 → `{갈래 기초|SK|고름 대기, 바탕, 입력값, 덮개, 자재표시, 더한줄, 뺀줄, 못옮김, 엑셀품, + 후보?}` · + `pick` = 사용자가 고른 기초 `{로직, 입력값?}`(옵션 없으면 거꾸로 찾음 · 늘 견줌).""" + entry = entry or {} + state = (entry.get("판정") or {}).get("상태") + efp = finger.excel(block, line) + if pick: + code = str(pick["로직"]) + given = pick.get("입력값") + if given is None: + given = option.find(code, efp, (block or {}).get("글") or [])["입력"] + elif state in ("맞음", "다른 점") and entry.get("차례"): + code = entry["차례"][0] + given = ((entry.get("옵션") or {}).get(code) or {}).get("입력") or {} + else: + return _wait(entry) + doc = {"키": "SK-000000", "이름": "견줌", "단위": line.get("단위") or "식", + "바탕": {"로직": code}, "입력값": dict(given), "덮개": {}, "뺀줄": [], "자재표시": {}} # fmt: skip + tb = float(calc.times_of(whole.logic(code))) + doc["더한줄"] = [] if pick else _extras(entry, code, whole, tb) + from M02_MasterTemplete import M02_Hopyo_Verdict as verdict # 판정이 Fit 을 부름 — 늦게 읽음 + + excel = verdict.excel_qty(efp) + head = {"바탕": doc["바탕"], "입력값": doc["입력값"], "엑셀품": excel} + if state == "맞음" and not pick and not doc["더한줄"]: + return {**head, "갈래": BASE, **dict.fromkeys(KEYS[:2], {}), **dict.fromkeys(KEYS[2:], [])} + work = _Plan(doc, whole) + work.labor({t: x["품"] for t, x in efp["인력"].items() if x.get("품")}) + work.machines([m for m in efp["장비"] if m.get("시간")], line) + work.materials([m for m in efp["자재"] if m.get("품")]) + out = {k: doc[k] for k in KEYS[:4]} + changed = any(out.values()) + return {**head, **out, "갈래": SK if changed else BASE, "못옮김": work.lost} From 68820e80f32145890e0ed80dbb507e90ed067af0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 10:51:18 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(729-2):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EC=9D=B4?= =?UTF-8?q?=EC=9D=8C=20=E2=80=94=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=EC=A4=84=20=3D=20SK=20=EC=B0=B8=EC=A1=B0=20=C2=B7=20=EB=85=B8?= =?UTF-8?q?=EC=9E=84=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=EA=B8=B0=EA=B3=84=20?= =?UTF-8?q?EQ=20=C2=B7=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9D=B4=EC=9D=8C?= =?UTF-8?q?=20=C2=B7=20=EB=82=A8=EC=9D=80=20=EC=A4=84=20=EC=9E=88=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20=EB=A7=89=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_Hopyo_Build · 새 M02_Hopyo_SkLink: 확인 안 한 단가산출 줄 = 그 블록 SK 참조(없으면 그 블록만 만듦) · 확인한 로직이 차이 SK 바탕이면 SK 참조 · 남은 줄 있으면 409 {까닭, 남은줄} · 미리 보기 unlinked(쓰기 없음) · build_all strict = 남은 줄 호표 실패(붙여넣기 길은 막지 않음) - M02_Hopyo_Fixed: Linker by_name(불러오기 길만) = 노임 이름 같은 LB · 기계 이름 · 규격 같은 EQ · 사용자 이음 force {노임|기계} · {비율} · linked() - M02_Labor_Router: hopyo-unlinked 길 · 대상 칸 노임 · 기계 · 비율 · 단가산출 몸 바탕 · hopyo-build-all strict - M02_Labor_Ref: 일위대가 목록 summary 안이은줄 - 시험: 새 test_729_hopyo_sk_link(북부청 기초잡석 · 철근 Type-Ⅰ · Ⅱ · 되메우기 SK 품 = 엑셀 품 · 기초 + 별칭 · 고름 대기 · 막힘 · 노임 · 기계 이음) · 낡은 시험 고침 — 718-1(모두 들여오기 = 남은 줄 호표 실패 · 단가산출 새 꼴) · hopyo_build · flow(남은 줄 「우리에 없음」 고른 뒤 만듦 · 빈 몸 = 409) · 705-3 · 705-2(모두 고정 = 막지 않는 build_all 직접) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Build.py | 92 +++++++++++- M02_MasterTemplete/M02_Hopyo_Fixed.py | 61 ++++++-- M02_MasterTemplete/M02_Hopyo_SkLink.py | 108 ++++++++++++++ M02_MasterTemplete/M02_Labor_Ref.py | 5 + M02_MasterTemplete/M02_Labor_Router.py | 41 +++++- resources/tester/test_705_2_hopyo_paste.py | 10 +- resources/tester/test_705_3_hopyo_fixed.py | 29 +++- resources/tester/test_718_1_hopyo_import.py | 36 ++--- resources/tester/test_729_hopyo_sk_link.py | 154 ++++++++++++++++++++ resources/tester/test_m02_hopyo_build.py | 17 ++- resources/tester/test_m02_hopyo_flow.py | 18 ++- 11 files changed, 509 insertions(+), 62 deletions(-) create mode 100644 M02_MasterTemplete/M02_Hopyo_SkLink.py create mode 100644 resources/tester/test_729_hopyo_sk_link.py diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index a34230d62..7da2f5d4d 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -25,6 +25,11 @@ 통째 대신 · 로직 자재 겹침 · 전국만. `본뜬.품셈` = 품셈으로 넣은 엑셀 줄 차례([품셈으로 바꾸기] 첫 체크) · `줄수 {품셈, 고정, 호표, 빠짐, 이음, 이음후보}` · 만든 값 ↔ 엑셀 호표 단가(`비교` · 같음 = 비목마다 1 원 안). 엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`). +이음(729-2 · 요구서 내역19 1장 8) = 확인 안 한 단가산출 줄 = 그 블록 SK 참조(이 층 · 같은 원본 · 없으면 +`M02_Hopyo_Sangeun` 으로 그 블록만 만듦 · 고름 대기면 못 이음) · 확인한 로직 줄이 차이 SK 의 바탕이면 그 SK +참조(차이 보존) · 노임 = 이름 · 기계 = EQ(`M02_Hopyo_Fixed.Linker`) · 사용자 이음 대상 `{노임|기계: 키}` · +`{비율: 기준}` = 고정 줄 + 그 이음 · 남은 줄(`Fixed.linked` 아님 · 「우리에 없음」 고른 줄 뺌) 있으면 409 +`{까닭, 남은줄}`(저장 안 함 · `save` 거짓 = 막지 않고 `남은줄` 만) · 미리 보기 = `unlinked`(쓰기 없음). 수량(723-1 · 내역 17-1) = 저장 때 걷음(`refs.no_qty`) · 엑셀 수량은 `본뜬.실무수량 {줄: {차례: 수}, 자재: {코드: 수}}` (출처 기록 · 셈 안 씀 · 엑셀 대조 `compare` 만 — 하위 호표 것까지 `excel_given`). """ @@ -43,6 +48,7 @@ from M02_MasterTemplete import M02_Hopyo_Fixed as hfix from M02_MasterTemplete import M02_Hopyo_Ledger as ledger from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Material as hmat +from M02_MasterTemplete import M02_Hopyo_SkLink as sklink from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs @@ -138,6 +144,10 @@ def _missing(line: dict[str, Any], why: str) -> dict[str, Any]: } +def _is_sk(one: dict[str, Any]) -> bool: + return str((one.get("참조") or {}).get("키") or "").startswith("SK-") + + def _bad(text: str, status: int = 400) -> store.StoreError: return store.StoreError(status, text) @@ -156,9 +166,13 @@ class Builder: self.hopyo = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == self.no), None) if self.hopyo is None: raise _bad(f"없는 호표 「제{no}호표」", 404) + self.src, self.dry, self._sk, self._maker = Path(source), False, None, None + self.strict = True # 남은 줄 있으면 409(붙여넣기 `build_all` = 거짓) # ── 재검사 ─────────────────────────────────────────────────────── def _target(self, raw: dict[str, Any], where: str, line: dict | None = None) -> dict[str, Any]: + if raw.get("노임") or raw.get("기계") or raw.get("비율"): + return self._link(raw, where, line) if raw.get("전국"): return self._national(raw, where, line) if raw.get("호표") is None: @@ -175,6 +189,23 @@ class Builder: times, part = ledger.times_of(raw, where) return {"호표": child, "배": times, "몫": part} + def _link(self, raw: dict[str, Any], where: str, line: dict | None) -> dict[str, Any]: + """사용자 이음 대상 — `{노임: LB}` · `{기계: EQ}` · `{비율: 기준 비목}`(% 줄 · 수량 = 율).""" + keys = [k for k in ("노임", "기계", "비율") if raw.get(k)] + extra = set(raw) - {"노임", "기계", "비율", "배", "몫", "입력값"} + if len(keys) != 1 or extra or raw.get("입력값"): + raise _bad(f"이음 대상은 노임 · 기계 · 비율 중 하나만 「{where}」") + kind, code = keys[0], str(raw[keys[0]]) + if kind == "비율": + if code not in labor.COSTS or line is None or finger.kind_of(line) != "%": + raise _bad(f"비율은 % 줄 · 노무비 · 재료비 · 경비 「{where}」") + if line.get("수량") is None: + raise _bad(f"% 줄 수량(율) 없음 「{where}」") + return {"이음": {"비율": code}} + if code[:2] != hfix.fixed.LINKS[kind] or not hfix.fixed._has(m01.loaded()[1], code): + raise _bad(f"{kind} 이음 「{code}」 M01 에 없음 「{where}」") + return {"이음": {kind: code}} + def _national(self, raw: dict[str, Any], where: str, line: dict | None) -> dict[str, Any]: """「전국」 대상 — 줄에서 서버가 다시 셈(이름 · 규격 · 단위 = 서버 것).""" if set(raw) - {"전국", "배", "몫", "입력값"} or raw.get("입력값"): @@ -236,11 +267,16 @@ class Builder: hopyo, file = self.hopyo, self.doc.get("파일") or "" lines, mats, missing, used, mat_lines = [], {}, [], set(), {} nation: dict[tuple, dict[str, Any]] = {} - taken, linker, poomsem, kids, kid_n = set(), hfix.Linker(), [], set(), 0 + taken, linker, poomsem, kids, kid_n = set(), hfix.Linker(self.strict and save), [], set(), 0 + left: list[dict[str, Any]] = [] where = {"파일": file, "호표": self.no} rows = hopyo.get("줄") or [] def put(line: dict[str, Any], one: dict[str, Any], alias: str | None) -> None: + if "이음" in one: + others = [x for x in rows if x is not line] + lines.append(hfix.line_of(line, others, where, taken, linker, one["이음"])) + return qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"])) memo = _memo(line, one) if "전국" in one: @@ -279,6 +315,9 @@ class Builder: lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) return row = {"로직": one["로직"], "입력값": one["입력값"], "메모": memo} + if (kept := sklink.kept(self, line, one, qty, alias, memo)) is not None: + lines.append(kept) + return if one["로직"] not in used: used.add(one["로직"]) lines.append({**row, "별칭": alias, "수량": {"손": _num(qty)}}) @@ -293,7 +332,7 @@ class Builder: "자재": [], "본뜬": self._origin(f"{mark(self.key, self.no, at)} {one['로직']}", stamp), } - ref, _ = _save(wrap) + ref = {"키": "SC-미리"} if self.dry else _save(wrap)[0] # 미리 보기 = 쓰기 없음 lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) book: dict[str, dict[str, Any]] = {} @@ -311,11 +350,15 @@ class Builder: if got and got.get("뺌") == "안 씀": missing.append(_missing(line, f"뺌 — {got['뺌']}")) continue - if not got or got.get("뺌"): # 빠짐 0(705-3) — 하위 호표 참조 · 아니면 고정 줄 + if not got or got.get("뺌"): # 빠짐 0(705-3) — 하위 호표 · SK 참조 · 아니면 고정 줄 kid = self._kid(line, kids) kid_n += bool(kid) + sk, why = (None, None) if kid or got else sklink.sk_line(self, line, save) others = [x for x in rows if x is not line] - lines.append(kid or hfix.line_of(line, others, where, taken, linker)) + one = kid or sk or hfix.line_of(line, others, where, taken, linker) + lines.append(one) + if not hfix.linked(one, bool(got)): + left.append(sklink.left(line, why)) continue poomsem.append(at) alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None @@ -339,18 +382,32 @@ class Builder: if nation: doc["본뜬"]["전국자재"] = list(nation.values()) doc["본뜬"]["품셈"] = poomsem + if self.dry: + return {"남은줄": left} + if left and save and self.strict: + raise store.StoreError(409, {"까닭": sklink.LEFT, "남은줄": left}) fixed_n = sum(1 for x in lines if "고정" in x) links = sum(1 for x in lines if any(((x.get("고정") or {}).get("이음") or {}).values())) count = {"품셈": len(poomsem), "고정": fixed_n, "호표": kid_n} count |= {"빠짐": sum(1 for x in missing if x["까닭"] != TWICE), "이음": links} + count["단가산출"] = sum(1 for x in lines if _is_sk(x)) if not save: ledger.write(self.folder, book, self.who, file or None) - return {"문서": doc, "빠진줄": missing, "줄수": {**count, "이음후보": linker.near}} + head = {"문서": doc, "빠진줄": missing, "남은줄": left} + return {**head, "줄수": {**count, "이음후보": linker.near}} ref, again = _save(doc) wrote = ledger.write(self.folder, book, self.who, file or None) out = {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}} return {**out, "줄수": {**count, "이음후보": linker.near}} + def unlinked(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> list: + """미리 보기 — 만들면 남을 줄(쓰기 없음 · SK 는 만들 수 있는지만 셈).""" + self.dry = True + try: + return self.build(checked, whole)["남은줄"] + finally: + self.dry = False + def _kid(self, line: dict[str, Any], kids: set[int]) -> dict[str, Any] | None: """확인 안 한 호표 줄 → 확인된 하위 사전조합 참조(단위 같음 · 같은 하위 한 번) · 아니면 None.""" ref = line.get("참조") or {} @@ -449,6 +506,21 @@ def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]: return {"엑셀": excel, "우리": ours, "차이": gap, "같음": same, "까닭": got.get("까닭")} +def unlinked( + folder: Path, + source: Path, + key: str, + no: int, + lines: list[dict[str, Any]] | None = None, + whole: dict[str, Any] | None = None, +) -> dict[str, Any]: + """안 이은 줄 미리 보기(729-3) — 몸 = 만들기 몸 → `{남은줄, 수}`(저장 없음).""" + maker = Builder(folder, source, key, no, None) + checked, pairs = maker.check(list(lines or []), whole) + left = maker.unlinked(checked, pairs) + return {"남은줄": left, "수": len(left)} + + def build( folder: Path, source: Path, @@ -486,8 +558,12 @@ def _order(units: dict[int, dict[str, Any]]) -> list[int]: return out -def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str, Any]: +def build_all( + folder: Path, source: Path, key: str, who: Any = None, strict: bool = False +) -> dict[str, Any]: """[모두 고정으로 들여오기](705-3) — 시트 호표 모두 · 하위 먼저 · 줄 = 고정(하위 = 참조) · + `strict`(불러오기 길 · 729-2) = 남은 줄 있는 호표는 안 만듦(`실패 {번, 까닭, 남은줄}`) · + 붙여넣기(705-2)는 막지 않음 · 이미 확인된 호표는 덮지 않음(`그대로`) → `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보, 같음, 그대로, 다름 [{번, 이름, 엑셀, 우리, 차이}], 실패 [{번, 까닭}], 만듦 [{번, 이름, 키}]}`.""" units = {int(h["번"]): h for h in hopyo_store.read(source, key).get("호표") or []} @@ -505,6 +581,7 @@ def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str out["줄"] += len(units[no].get("줄") or []) try: maker = Builder(folder, source, key, no, who) + maker.strict = strict have = maker.done.get(mark(key, no)) if have: out["그대로"] += 1 @@ -517,7 +594,8 @@ def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str got = compare(ref, maker.hopyo) out["만듦"].append({"번": no, "이름": units[no].get("이름"), "키": ref["키"]}) except store.StoreError as e: - out["실패"].append({"번": no, "까닭": str(e.detail)}) + why = e.detail if isinstance(e.detail, dict) else {"까닭": str(e.detail)} + out["실패"].append({"번": no, **why}) continue if got["같음"]: out["같음"] += 1 diff --git a/M02_MasterTemplete/M02_Hopyo_Fixed.py b/M02_MasterTemplete/M02_Hopyo_Fixed.py index 262697496..f21f81580 100644 --- a/M02_MasterTemplete/M02_Hopyo_Fixed.py +++ b/M02_MasterTemplete/M02_Hopyo_Fixed.py @@ -4,8 +4,9 @@ 숫자는 서버가 실무자료 파일에서 읽음(브라우저 값 안 받음). 단가 = 엑셀 단가 노무비 · 재료비 · 경비(빈 칸 0) · 합계만 있는 줄 = 재료비(4장 3) · 수량 없음 = 수량 1 · 단가 = 엑셀 금액(「금액만」). - 이음 = 이름 · 규격 · 단위 · 단가가 다 같은 노임(LB) · 자재(MT)만 저절로 → 엑셀 합계 = 우리 합계 · - 단가가 다른 짝 = `후보` 수만(바꾸기는 705-2 「이 공사 단가로 다시 잇기」). + 이음 = 노임(LB) · 자재(MT) = 이름 · 규격 · 단위 · 단가 다 같은 것만 · `by_name`(729-2 불러오기 길 · + 요구서 내역19 3장 7) = 노임 = 이름 같은 LB 하나(값 = 지금 단가 책) · 기계 = 이름(갈래) · 규격 수 같은 EQ + 하나 · 못 이은 짝 = `후보` 수만 · 사용자가 고른 이음(`force` {노임|기계|자재: 키} · {비율: 기준}) = 그대로. % 줄 = 비율 줄 `비율 {기준, 비목}` — 엑셀 밑수(단가 계) = 다른 줄 금액[기준] 합일 때만 · 아니면(주재료비 등) 수량 1 · 「식」 · 단가 = 엑셀 금액. 같은 이름 · 규격 · 단위 둘째부터 = 이름 끝 「 (2)」(줄 가름 · 705-1 400 피함). @@ -25,6 +26,7 @@ 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 COSTS = ("노무비", "재료비", "경비") _BASE = re.compile(r"(? dict[str, Decimal]: @@ -56,29 +59,62 @@ def _ratio(line: dict[str, Any], others: list[dict[str, Any]]) -> dict[str, str] return {"기준": base, "비목": paid[0]} if abs(total - under) <= GAP else None -class Linker: - """이음 — 노임(LB 이름 같음) · 자재(이름 · 규격 같은 한 품목 · 단위 같음)가 단가까지 같을 때만.""" +def linked(one: dict[str, Any], decided: bool = False) -> bool: + """일위대가 줄 하나가 M01 · 기초 · SK 에 이어짐 — 로직 · 참조 · 비율 · 이음 있는 고정 줄 · + `decided` = 사용자가 「우리에 없음」으로 고른 줄.""" + fx = one.get("고정") if isinstance(one, dict) else None + if not isinstance(fx, dict): + return True + return decided or bool(fx.get("비율")) or any((fx.get("이음") or {}).values()) - def __init__(self) -> None: + +class Linker: + """이음 — 노임(LB 이름) · 기계(EQ 갈래 · 규격) · 자재(이름 · 규격 같은 한 품목 · 단위 · 단가 같음).""" + + def __init__(self, by_name: bool = False) -> None: + self.by_name = ( + by_name # 729-2 불러오기 길만 — 붙여넣기 · B09 통째 = 단가 같을 때만(엑셀 합계 그대로) + ) self.whole = m01.loaded()[1] self.got = hm.targets() self.labor: dict[str, list[tuple[str, Decimal | None]]] = {} for row in self.whole.numbers.get("LB", {}).values(): pair = (str(row.get("키")), _d(row.get("값"))) self.labor.setdefault(str(row.get("이름") or ""), []).append(pair) - self.near = 0 # 단가 다른 이음 후보 수 + self.machines = [ + (str(row.get("키")), mtypes(f"{row.get('상세구분')} {row.get('이름')}"), + set(specnums(row.get("규격"))), mm_plain(row.get("이름")), mm_plain(row.get("규격"))) + for row in self.whole.numbers.get("EQ", {}).values() + ] # fmt: skip + self.near = 0 # 못 이은 이음 후보 수 def link(self, line: dict[str, Any], price: dict[str, Decimal]) -> dict[str, str | None]: out: dict[str, str | None] = {"노임": None, "자재": None, "기계": None} kind = finger.kind_of(line) if kind == "노임": pairs = self.labor.get(shares.trade(line.get("이름")) or "", []) - out["노임"] = self._same(pairs, price["노무비"]) + one = [code for code, _ in pairs] + same = one[0] if self.by_name and len(one) == 1 else None + out["노임"] = same or self._same(pairs, price["노무비"]) + elif kind == "기계" and self.by_name: + out["기계"] = self._machine(line) elif kind in hm.MATERIAL_KINDS and price["재료비"]: out["자재"] = self._same(self._mats(line), price["재료비"]) - # ponytail: 기계 이음 없음 — 기계 시간 단가는 B09 중기목록표 셈 · 같은 값 판정 못 함 return out + def _machine(self, line: dict[str, Any]) -> str | None: + """기계 줄 → EQ 하나 — 이름 · 규격 글 같음 › 갈래 겹침 + 규격 수 같음(하나일 때만).""" + 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] + 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] + if len(same) == 1: + return same[0] + self.near += bool(same) + return None + def _same(self, pairs: list[tuple[str, Decimal | None]], want: Decimal) -> str | None: same = [code for code, value in pairs if value is not None and abs(value - want) < GAP] if len(same) == 1: @@ -110,11 +146,16 @@ def line_of( where: dict[str, Any], taken: set[str], linker: Linker, + force: dict[str, str] | None = None, ) -> dict[str, Any]: - """엑셀 줄 하나 → 일위대가 고정 줄 `{고정, 별칭, 메모, 수량}` — `taken` = 이 호표 줄 가름(채워 감).""" + """엑셀 줄 하나 → 일위대가 고정 줄 `{고정, 별칭, 메모, 수량}` — `taken` = 이 호표 줄 가름(채워 감) · + `force` = 사용자가 고른 이음 `{노임|기계|자재: 키}` · 비율 `{비율: 기준 비목}`(수량 = 율 %).""" qty = _d(line.get("수량")) spec, unit = str(line.get("규격") or "").strip(), str(line.get("단위") or "").strip() rate = _ratio(line, others) if finger.kind_of(line) == "%" else None + if force and force.get("비율"): + paid = [k for k, v in _parts(line.get("금액")).items() if v] + rate = {"기준": force["비율"], "비목": paid[0] if len(paid) == 1 else force["비율"]} memo = f"실무 {qty if qty is not None else ''}{unit} 그대로" if rate: price = dict.fromkeys(COSTS, Decimal(0)) @@ -135,6 +176,8 @@ def line_of( "단가": {k: _plain(v) for k, v in price.items()}, } fx["이음"] = dict.fromkeys(fixed.LINKS) if rate else linker.link(line, price) + if force and not rate: + fx["이음"] = {k: force.get(k) for k in fixed.LINKS} fx["출처"] = {**where, "줄": int(line.get("차례", 0))} if rate: fx["비율"] = rate diff --git a/M02_MasterTemplete/M02_Hopyo_SkLink.py b/M02_MasterTemplete/M02_Hopyo_SkLink.py new file mode 100644 index 000000000..e2f990493 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_SkLink.py @@ -0,0 +1,108 @@ +"""M02 일위대가 만들기 — 단가산출(SK) 이음 · 남은 줄(PLAN 729-2 · 요구서 내역19 1장 8). + +`M02_Hopyo_Build.Builder` 가 부름(`b` = 그 만들기) — +확인 안 한 단가산출 줄 = 그 블록 SK 참조(이 층 · 같은 원본 · 없으면 `M02_Hopyo_Sangeun.Maker` 로 그 블록만 +만듦 · 미리 보기 = 키만 · 맞춤 셈 전 · 고름 대기 · 단위 다름 = 못 이음) · +확인한 로직 줄이 차이 SK 의 바탕 = 그 SK 참조(엑셀 차이 보존 · 별칭 짝 = 기초까지만) · +남은 줄 = 엑셀 줄 · 갈래 · 까닭 · 후보(단가산출 고름 대기). +""" + +from __future__ import annotations + +import json +from decimal import Decimal +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Finger as finger +from M02_MasterTemplete import M02_Hopyo_Sangeun as sangeun +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs + +mm_unit = m01.cm.mf.mp.mm.plain_unit +LEFT = "안 이은 줄이 남음 — 줄마다 이을 것을 고름" +WHY = { + "단가산출": "맞는 기초 없음 — 고름 대기", + "자재": "자재 후보 여럿 · 없음", + "별도계상": "자재 후보 여럿 · 없음", + "노임": "같은 이름 노임 없음", + "기계": "같은 기계(EQ) 없음 · 여럿", + "호표": "하위 호표 확인 전 · 단위 다름", + "%": "밑수가 다른 줄 금액 합 아님", +} + + +def _num(value: Decimal) -> int | float: + value = value.normalize() + return int(value) if value == value.to_integral_value() else float(value) + + +def sks(b: Any) -> dict[str, str]: + """이 층 그 HP 의 SK `{본뜬 키: SK 키}`(한 번 읽어 둠).""" + if b._sk is None: + b._sk = sangeun.made(b.folder, b.key, b.source) + return b._sk + + +def sk_doc(b: Any, key: str) -> dict[str, Any]: + try: + got = json.loads((b.folder / "sangeun" / f"{key}.json").read_bytes()) + except (OSError, ValueError): + return {} + return got if isinstance(got, dict) else {} + + +def _ref(key: str, line: dict, alias: Any, memo: str, qty: Decimal) -> dict[str, Any]: + head = {"층": refs.here()[0], "주인": refs.here()[1], "키": key} + return {"참조": head, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}} + + +def sk_line(b: Any, line: dict[str, Any], create: bool) -> tuple[dict | None, dict | None]: + """단가산출 줄 → (그 블록 SK 참조 줄, 못 이은 까닭 {까닭, 후보?}) — `create` = 없으면 만듦.""" + ref = line.get("참조") or {} + if ref.get("종류") != "단산": + return None, None + no = int(ref.get("번") or 0) + key = sks(b).get(sangeun.mark(b.key, no)) + if key is None and create: + if b._maker is None: + b._maker = sangeun.Maker(b.src, b.key, b.who) + if no not in b._maker.first: + return None, None + if not b._maker.done: + return None, {"까닭": "맞춤 셈 전 — 단가산출 못 만듦"} + plan = b._maker.plan(no) + if plan["갈래"] == sangeun.skpatch.WAIT: + return None, {"까닭": plan["까닭"], "후보": plan["후보"]} + key = f"SK-미리{no}" if b.dry else b._maker.write(no, plan, None) + sks(b)[sangeun.mark(b.key, no)] = key + if key is None: + return None, None + unit = sk_doc(b, key).get("단위") or line.get("단위") + if mm_unit(unit) != mm_unit(line.get("단위")): + return None, {"까닭": f"단가산출 단위 「{unit}」 다름"} + qty = Decimal(str(line.get("수량") or 0)) + alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None + return _ref(key, line, alias, f"실무 {qty}{line.get('단위') or ''} · 단산{no}", qty), None + + +def kept(b: Any, line: dict, one: dict, qty: Decimal, alias: Any, memo: str) -> dict | None: + """확인한 로직 줄이 그 블록 차이 SK 의 바탕 = 그 SK 참조 · 아니면 None.""" + ref = line.get("참조") or {} + if ref.get("종류") != "단산" or one.get("몫") is not None: + return None + key = sks(b).get(sangeun.mark(b.key, int(ref.get("번") or 0))) + doc = sk_doc(b, key) if key else {} + base = (doc.get("바탕") or {}).get("로직") + if (doc.get("본뜬") or {}).get("갈래") != sangeun.skpatch.SK or base != one["로직"]: + return None + return _ref(key, line, alias, f"{memo} · 차이 SK", qty) + + +def left(line: dict[str, Any], why: dict | None) -> dict[str, Any]: + """남은 줄 한 줄 — 엑셀 줄 · 갈래 · 까닭 · 후보.""" + kind = finger.kind_of(line) + keep = ("차례", "이름", "규격", "단위", "수량") + out = {**{k: line.get(k) for k in keep}, "갈래": kind} + out["까닭"] = (why or {}).get("까닭") or WHY.get(kind, "이을 것 없음") + return {**out, "후보": (why or {}).get("후보") or []} diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index ced3611f8..ad09e464e 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -314,6 +314,11 @@ def summary(folder: Path, kind: str, key: str, expand: Expand | None = None) -> layer, owner = where(folder) base = expand({"층": layer, "주인": owner, "키": key}).get("기준") or {} out["기준금액"] = float(base["계"]) if "계" in base else None + if kind == "precombo": # 729-2 — 이음 없는 고정 줄 수(로직 · 참조 · 비율 · 이음 = 이어짐) + fixes = [x["고정"] for x in doc.get("담은로직") or [] if isinstance(x.get("고정"), dict)] + out["안이은줄"] = sum( + 1 for x in fixes if not x.get("비율") and not any((x.get("이음") or {}).values()) + ) # 717-2a 목록 열 — 바탕(로직 「키 이름」 | 참조 키 | None) · 고친 줄 수 · 쓰는 곳 수 if kind == "sangeun": top, (layer, owner) = doc.get("바탕") or {}, where(folder) diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 0e8da10b7..a0951097d 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -375,6 +375,9 @@ class BuildTarget(_Strict): 자재: str | None = None 호표: int | None = None 전국: bool | None = None # 「전국」 자재(361-1 · 키 없음 · 서버가 줄에서 다시 셈) + 노임: str | None = None # 729-2 사용자 이음 — LB 키(엑셀 숫자 + 이음) + 기계: str | None = None # EQ 키 + 비율: Literal["노무비", "재료비", "경비"] | None = None # % 줄 밑수 입력값: dict[str, Any] = Field(default_factory=dict) 배: float = 1 몫: float | None = None @@ -422,6 +425,23 @@ def hopyo_make( return _call(folder, hopyo_build.build, *args) +@router.post("/layers/{layer}/hopyo-unlinked") +def hopyo_unlinked( + layer: Layer, + body: BuildBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """안 이은 줄 미리 보기(729-2) — 만들기 몸 그대로 → `{남은줄 [{차례, 이름, 규격, 단위, 갈래, 까닭, + 후보}], 수}`(저장 없음).""" + folder = _root(session, layer, owner, write=True) + source = _root(session, body.층, body.주인) + lines = [one.model_dump(exclude_none=True) for one in body.줄] + whole = body.통째.model_dump(exclude_none=True) if body.통째 else None + args = (folder, source, body.키, body.번, lines, whole) + return _call(folder, hopyo_build.unlinked, *args) + + class BuildAllBody(_Strict): """시트 통째(705-3) — 파일 층 `층` · `주인` · `키` 만(숫자 · 줄 칸 없음 · 서버가 파일에서 읽음).""" @@ -441,7 +461,7 @@ def hopyo_make_all( `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보, 같음, 그대로, 다름, 실패}`.""" folder = _root(session, layer, owner, write=True) source = _root(session, body.층, body.주인) - args = (folder, source, body.키, _who(session)) + args = (folder, source, body.키, _who(session), True) # 729-2 — 남은 줄 있는 호표 = 실패 return _call(folder, hopyo_build.build_all, *args) @@ -503,14 +523,25 @@ def _finish(folder: Path, key: str) -> dict[str, Any]: return {"지움": key, "만든": len(made)} +class SangeunBody(_Strict): + """`바탕` = 고름 대기 블록에 사용자가 고른 기초 `{블록 번: {로직, 입력값?}}`(729-1).""" + + 바탕: dict[str, dict[str, Any]] = Field(default_factory=dict) + + @router.post("/layers/{layer}/hopyo/{key}/sangeun") def hopyo_make_sangeun( - layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) + layer: Layer, + key: str, + body: SangeunBody | None = None, + owner: str | None = None, + session: Session = Depends(verify_session), ) -> dict: - """불러오기 결과 「단가산출」(718-1) — 산근 블록마다 바탕 없는 단가산출(파일 층 = 이 층) → - `{블록, 만듦, 그대로}`.""" + """불러오기 결과 「단가산출」(718-1 · 729-1) — 산근 블록마다 매칭 → 비교 → 기초 · SK · 고름 대기 + (파일 층 = 이 층) → `{블록, 만듦, 그대로, 기초, SK, 고름대기, 별칭}` · 맞춤 셈 전 409.""" folder = _root(session, layer, owner, write=True) - return _call(folder, hopyo_sangeun.build, folder, key, _who(session)) + picks = body.바탕 if body else {} + return _call(folder, hopyo_sangeun.build, folder, key, _who(session), picks) class PasteBody(_Strict): diff --git a/resources/tester/test_705_2_hopyo_paste.py b/resources/tester/test_705_2_hopyo_paste.py index c89f9522a..69535a43e 100644 --- a/resources/tester/test_705_2_hopyo_paste.py +++ b/resources/tester/test_705_2_hopyo_paste.py @@ -183,9 +183,13 @@ def test_다시_잇기_후보_바꿈_되돌리기(client): from resources.tester.test_m02_hopyo_excel import _post assert _post(client).status_code == 200 # 북부청 — 이음 13 · 이음 후보 31(705-3) - made = client.post( - "/api/m02/layers/personal/hopyo-build-all", json={"층": "personal", "키": "HP-000001"} - ).json() + from M02_MasterTemplete import M02_Hopyo_Build as hb + from M02_MasterTemplete import M02_MasterTemplete_Store as mstore + from M02_MasterTemplete import M02_Template_Layers as mlayers + + mine = mlayers.personal_dir(7, 42) # 모두 고정(막지 않음) — 길은 남은 줄 호표 실패(729-2) + with mstore.at(mine): + made = hb.build_all(mine, mine, "HP-000001") keys = [x["키"] for x in made["만듦"]] assert len(keys) == 68 rows = client.post(f"{RELINK}/rows", json={"키": keys}).json()["줄"] diff --git a/resources/tester/test_705_3_hopyo_fixed.py b/resources/tester/test_705_3_hopyo_fixed.py index ed3822245..a35f48770 100644 --- a/resources/tester/test_705_3_hopyo_fixed.py +++ b/resources/tester/test_705_3_hopyo_fixed.py @@ -2,9 +2,10 @@ - 확인 안 한 줄 · 노임 · % · 「우리에 없음」 = 고정 줄(엑셀 숫자 그대로) · 체크한 줄만 품셈 · 하위 호표 = 참조 - % 줄 = 비율 고정 줄 — M02 settle 거울(파이썬 ↔ TS) · 사전조합 셈 · B09 `pct` 줄 -- [모두 고정으로 들여오기] — 북부청 · 실무 xlsx 넷: 빠짐 0 · 엑셀 합계 = 우리 합계(호표마다) · 줄 꼬리표 수 · - 확인된 호표 그대로 · 이음 = 단가까지 같은 줄만 · 같은 이름 줄 차례 가름 · 몸에 숫자 칸 없음(422) -- [품셈으로 바꾸기] 전후 — 체크 한 줄 = 품셈 · 나머지 고정 · 되돌리기(빈 몸) = 모두 고정 +- 모두 고정(`build_all` 막지 않음 = 붙여넣기 길) — 북부청 · 실무 xlsx 넷: 빠짐 0 · 엑셀 합계 = 우리 합계 · + 줄 꼬리표 수 · 확인된 호표 그대로 · 이음 = 단가까지 같은 줄만(노임 이름 · 기계 EQ = 불러오기 길만) · 같은 이름 줄 차례 + 가름 · 몸에 숫자 칸 없음(422) · [모두 고정으로 들여오기] 길 = 남은 줄 있는 호표 실패(729-2 · 718-1 시험) +- [품셈으로 바꾸기] 전후 — 체크 한 줄 = 품셈 · 나머지 고정(「우리에 없음」 고름) · 되돌리기 = 모두 고정 층은 임시 폴더(공용 storage 안 씀). """ @@ -16,7 +17,9 @@ import pytest from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor from B09_Estimation.B09_Estimation_Labor_Source import fixed_line +from M02_MasterTemplete import M02_Hopyo_Build as hb from M02_MasterTemplete import M02_Hopyo_Fixed as hfix +from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Template_Layers as layers @@ -59,9 +62,19 @@ def _fx(name, price, rate=None, unit="식"): def _all(client, key="HP-000001"): - res = client.post(ALL.format("personal"), json={"층": "personal", "키": key}) - assert res.status_code == 200, res.text - return res.json() + """모두 고정(막지 않음 = 붙여넣기 길 `build_all`) — 길(729-2)은 남은 줄 있는 호표 = 실패.""" + folder = layers.personal_dir(7, 42) + with store.at(folder): + return hb.build_all(folder, folder, key) + + +def _decided(client, body): + """남은 줄(729-2)을 「우리에 없음」으로 고른 몸.""" + left = client.post(BUILD.format("personal").replace("build", "unlinked"), json=body).json() + return { + **body, + "줄": [*body["줄"], *({"차례": x["차례"], "뺌": "우리에 없음"} for x in left["남은줄"])], + } def _docs(): @@ -213,7 +226,7 @@ def test_품셈으로_바꾸기_되돌리기(client): _all(client) body = {"층": "personal", "키": "HP-000001", "번": 3} one = {"차례": 1, "대상": [{"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 1}]} - res = client.post(BUILD.format("personal"), json={**body, "줄": [one]}) + res = client.post(BUILD.format("personal"), json=_decided(client, {**body, "줄": [one]})) assert res.status_code == 200, res.text after = res.json() assert after["다시씀"] is True and after["줄수"]["품셈"] == 1 and after["줄수"]["고정"] == 3 @@ -222,5 +235,5 @@ def test_품셈으로_바꾸기_되돌리기(client): assert after["비교"]["같음"] is False # 품셈 줄 = 우리 값(엑셀과 다름) view = client.get("/api/m02/layers/personal/hopyo/HP-000001/3").json() assert view["호표"]["만든"]["품셈"] == [1] and view["호표"]["만든"]["고정"] == 3 - back = client.post(BUILD.format("personal"), json={**body, "줄": []}).json() # 되돌리기 + back = client.post(BUILD.format("personal"), json=_decided(client, {**body, "줄": []})).json() assert back["줄수"]["품셈"] == 0 and back["비교"]["같음"] is True diff --git a/resources/tester/test_718_1_hopyo_import.py b/resources/tester/test_718_1_hopyo_import.py index 72f75bb11..c3a5f6ef3 100644 --- a/resources/tester/test_718_1_hopyo_import.py +++ b/resources/tester/test_718_1_hopyo_import.py @@ -27,7 +27,8 @@ def test_이어서_끝내기_지움_겹침_막음(client): assert again["키"] == "HP-000001" and again["이어서"] is True # 작업 중 같은 엑셀 = 새 키 없음 assert _keys(client) == ["HP-000001"] made = client.post(ALL, json={"층": "personal", "키": "HP-000001"}).json() - assert len(made["만듦"]) == 68 + n = len(made["만듦"]) # 729-2 — 남은 줄 있는 호표 = 실패(남은줄) + assert n and n + len(made["실패"]) == 68 and all(x["남은줄"] for x in made["실패"]) hs.remember(folder, {"장부 줄": {"갈래": "안 씀"}}) # 별칭 장부 = 끝내기 뒤 남음 cache = fit.cache_path(folder, "HP-000001") cache.write_text("{}", encoding="utf-8") @@ -36,7 +37,7 @@ def test_이어서_끝내기_지움_겹침_막음(client): sc_before = sorted((folder / "precombo").glob("SC-*.json")) res = client.delete(URL.format("personal") + "/HP-000001") - assert res.status_code == 200 and res.json() == {"지움": "HP-000001", "만든": 68} + assert res.status_code == 200 and res.json() == {"지움": "HP-000001", "만든": n} assert not list((folder / hs.DIRNAME).glob("HP-*.json")) # 이번 HP 0 assert not list((folder / hs.DIRNAME / hs.GRID).glob("HP-*.json")) assert not cache.exists() and not old_cache.exists() @@ -45,7 +46,7 @@ def test_이어서_끝내기_지움_겹침_막음(client): block = _post(client) # 끝낸 같은 엑셀 = 막음 · 안내 assert block.status_code == 409 and "이미 불러온 엑셀" in block.json()["detail"] - assert "단가산출 68" in block.json()["detail"] and _keys(client) == [] + assert f"단가산출 {n}" in block.json()["detail"] and _keys(client) == [] assert sorted((folder / "precombo").glob("SC-*.json")) == sc_before @@ -67,32 +68,25 @@ def test_옛_HP_목록에서만_숨김(client): assert old.is_file() and _keys(client) == ["HP-000008"] -def test_결과_단가산출_산근_블록_바탕_없음_겹침_없음(client): +def test_결과_단가산출_산근_블록_기초_SK_고름대기_겹침_없음(client): folder = layers.personal_dir(7, 42) assert _post(client).json()["키"] == "HP-000001" url = URL.format("personal") + "/HP-000001/sangeun" + assert client.post(url).status_code == 409 # 729-1 — 맞춤 셈 전 + fit.run_job(folder, "HP-000001") got = client.post(url) assert got.status_code == 200, got.text got = got.json() - assert got["블록"] > 0 and len(got["만듦"]) == got["블록"] and got["그대로"] == 0 + wait = len(got["고름대기"]) + assert got["블록"] > 0 and len(got["만듦"]) + wait == got["블록"] and got["그대로"] == 0 docs = [json.loads(p.read_bytes()) for p in sorted((folder / "sangeun").glob("SK-*.json"))] - assert len(docs) == got["블록"] + assert len(docs) == len(got["만듦"]) == got["기초"] + got["SK"] one = docs[0] - assert one["바탕"] is None and one["본뜬"]["키"].startswith("HP-000001 단산") - assert one["구분"] == "실무자료" and len(one["더한줄"]) == 1 and one["단위"] - hp = hs.read(folder, "HP-000001") - no = int(one["본뜬"]["키"].rsplit("단산", 1)[1]) - line = next( # 그 블록을 부르는 첫 엑셀 줄 = 엑셀 단가 그대로 - x - for h in hp["호표"] - for x in h["줄"] - if (x.get("참조") or {}).get("종류") == "단산" and x["참조"]["번"] == no - ) - price = one["더한줄"][0]["고정"]["단가"] - assert sum(price.values()) == sum((line["단가"] or {}).get(k) or 0 for k in price) + assert one["바탕"]["로직"] and one["본뜬"]["키"].startswith("HP-000001 단산") # 바탕 없는 SK 0 + assert one["구분"] == "실무자료" and one["단위"] and one["본뜬"]["갈래"] in ("기초", "SK") again = client.post(url).json() # 다시 = 그대로(겹침 없음) - assert again["만듦"] == [] and again["그대로"] == got["블록"] + assert again["만듦"] == [] and again["그대로"] == len(docs) res = client.delete(URL.format("personal") + "/HP-000001").json() - assert res["만든"] == got["블록"] # 단가산출만 만들어도 지문 기록 - assert len(list((folder / "sangeun").glob("SK-*.json"))) == got["블록"] # 남음 + assert res["만든"] == len(docs) # 단가산출만 만들어도 지문 기록 + assert len(list((folder / "sangeun").glob("SK-*.json"))) == len(docs) # 남음 assert _post(client).status_code == 409 diff --git a/resources/tester/test_729_hopyo_sk_link.py b/resources/tester/test_729_hopyo_sk_link.py new file mode 100644 index 000000000..ba8a8087d --- /dev/null +++ b/resources/tester/test_729_hopyo_sk_link.py @@ -0,0 +1,154 @@ +"""실무문서 단가산출 → 기초 · SK · 고름 대기 · 일위대가 이음 · 남은 줄 막음(PLAN 729-1 · 729-2 · 요구서 내역19). + +북부청 임도(실물 엑셀)를 임시 층에 올려 맞춤셈 → [단가산출 만들기] → +기초잡석채집 · 운반 · 다짐(엑셀만 덤프트럭) · 철근가공조립 Type-Ⅰ · Type-Ⅱ · 구조물되메우기 = SK · +SK 품 = 엑셀 품(차이는 엑셀 값 그대로) · 같은 단가산출 = 기초(덮개 없음) + 별칭 제안(짝 = 기초까지만) · +고름 대기 = SK 안 만듦 → 고른 기초로 SK · 안 이은 줄 남으면 만들기 409(미리 보기 = 같은 줄) · +사용자가 고르면 만듦 · 노임 = 이름 이음 · 기계 = EQ 이음 · master_data 원본 그대로. 층 = 임시 폴더. +""" + +import json + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Fit as fit +from M02_MasterTemplete import M02_Hopyo_Fixed as hfix +from M02_MasterTemplete import M02_Hopyo_Ledger as ledger +from M02_MasterTemplete import M02_Hopyo_Option as option +from M02_MasterTemplete import M02_Hopyo_Ours as ours +from M02_MasterTemplete import M02_Hopyo_SkPatch as skpatch +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_hopyo_excel import URL, _post, client, master_hash # noqa: F401 + +BUILD = "/api/m02/layers/personal/hopyo-build" +LEFT = "/api/m02/layers/personal/hopyo-unlinked" +SAMPLES = { # 블록 번 → (이름 앞, 엑셀 규격 글) + 33: "기초잡석채집,운반,다짐", + 47: "철근가공조립(현장)", # Type-Ⅰ(소량 시공) — 철근공 4.2 · 보통인부 1.4 + 72: "철근가공조립(현장)", # Type-ll — 철근공 2.67 · 보통인부 1.02 + 12: "구조물되메우기(인력100%)", +} + + +def _same(a: float, b: float) -> bool: + return abs(a - b) <= 1e-9 or option.near(a, b, fit.TOL[0], option.ru(a)) + + +def _by_type(rows: list[dict]) -> dict[str, float]: + out: dict[str, float] = {} + for x in rows: + k = "+".join(sorted(x["갈래"])) + out[k] = out.get(k, 0.0) + x["시간"] + return out + + +def _sk_docs(folder) -> dict[int, dict]: + out = {} + for path in sorted((folder / "sangeun").glob("SK-*.json")): + doc = json.loads(path.read_bytes()) + out[int(doc["본뜬"]["키"].rsplit("단산", 1)[1])] = doc + return out + + +def test_실무_단가산출_기초_SK_고름대기_일위대가_막음(client): + before = master_hash() + folder = layers.personal_dir(7, 42) + key = _post(client).json()["키"] + url = f"{URL.format('personal')}/{key}/sangeun" + assert client.post(url).status_code == 409 # 맞춤 셈 전 + fit.run_job(folder, key) + got = client.post(url, json={}) + assert got.status_code == 200, got.text + got = got.json() + assert ( + got["기초"] + got["SK"] + len(got["고름대기"]) + == got["블록"] + == len(got["만듦"]) + len(got["고름대기"]) + ) + assert got["SK"] > 0 and got["기초"] > 0 and got["고름대기"] + docs, whole = _sk_docs(folder), m01.loaded()[1] + assert all(d["바탕"] and d["바탕"]["로직"] for d in docs.values()) # 바탕 없는 SK 0 + assert not set(docs) & {x["번"] for x in got["고름대기"]} + # SK 품 = 엑셀 품 + for no, head in SAMPLES.items(): + doc = docs[no] + assert doc["이름"].startswith(head) and doc["본뜬"]["갈래"] == "SK", (no, doc["본뜬"]) + excel = doc["본뜬"]["엑셀품"] + with store.at(folder): + ours_q = skpatch.measure(doc, whole) + assert set(ours_q["인력"]) == set(excel["인력"]), no + assert all(_same(q, ours_q["인력"][t]) for t, q in excel["인력"].items()), no + theirs, mine = _by_type(excel["장비"]), _by_type(ours_q["장비"]) + assert set(theirs) == set(mine), no + assert all(_same(q, mine[t]) for t, q in theirs.items()), no + dump = [x for x in docs[33]["더한줄"] if x.get("로직") == "GC000268"] + assert any("덤프트럭" in ours.eq_of(_eq(x))[0] for x in dump) # 엑셀만 장비 = 더한줄 + assert docs[12]["덮개"]["labor:LB000002#0"]["qty"] == 0.1 # 엑셀 값 그대로(우리 0.01) + assert docs[47]["더한줄"][0]["로직"] == "GC000206" # 조합 둘째 = 더한줄 기초 + # 같은 단가산출 = 기초 + 별칭 제안(짝 = 기초까지만) + base = next(d for d in docs.values() if d["본뜬"]["갈래"] == "기초") + assert base["덮개"] == {} and base["더한줄"] == [] and base["뺀줄"] == [] + book = ledger.read(folder) + hp = hs.read(folder, key) + lines = { + int(x["참조"]["번"]): x + for h in reversed(hp["호표"]) + for x in reversed(h["줄"]) + if (x.get("참조") or {}).get("종류") == "단산" + } + no = int(base["본뜬"]["키"].rsplit("단산", 1)[1]) + pair = book[ledger.key(lines[no])]["짝"] + assert pair == [{"로직": base["바탕"]["로직"], "입력값": base["입력값"], "배": 1, "몫": None}] + assert book[ledger.key(lines[12])]["짝"][0]["로직"] == docs[12]["바탕"]["로직"] + assert got["별칭"] > 0 + # 다시 = 그대로 · 고름 대기 = 고른 기초로 SK + again = client.post(url, json={}).json() + assert again["만듦"] == [] and again["그대로"] == len(docs) + wait = got["고름대기"][0] + code = (wait["후보"] or [{"로직": "GF000113"}])[0]["로직"] + picked = client.post(url, json={"바탕": {str(wait["번"]): {"로직": code}}}).json() + assert [x["번"] for x in picked["만듦"]] == [wait["번"]] + assert _sk_docs(folder)[wait["번"]]["바탕"]["로직"] == code + assert client.post(url, json={"바탕": {"9999": {"로직": code}}}).status_code == 404 + assert client.post(url, json={"바탕": {str(wait["번"]): {"로직": "XX"}}}).status_code == 400 + # 안 이은 줄 남으면 만들기 막힘 · 미리 보기 = 같은 줄 · 고르면 만듦 + body = {"층": "personal", "키": key, "번": 1, "줄": []} + left = client.post(LEFT, json=body).json() + res = client.post(BUILD, json=body) + assert res.status_code == 409 and left["수"] > 0 + assert res.json()["detail"]["남은줄"] == left["남은줄"] + assert not list((folder / "precombo").glob("SC-*.json")) # 막힘 = 저장 0 + picks = [{"차례": x["차례"], "뺌": "우리에 없음"} for x in left["남은줄"]] + ok = client.post(BUILD, json={**body, "줄": picks}) + assert ok.status_code == 200, ok.text + doc = json.loads((folder / "precombo" / f"{ok.json()['참조']['키']}.json").read_bytes()) + refs_sk = [x for x in doc["담은로직"] if str((x.get("참조") or {}).get("키")).startswith("SK")] + assert refs_sk # 단가산출 줄 = SK 참조(고정 줄 대신) + assert client.post(LEFT, json={**body, "줄": picks}).json()["수"] == 0 + assert master_hash() == before + + +def _eq(one: dict) -> str: + return str((one.get("입력값") or {}).get("기계") or "") + + +def test_노임_이름_이음_기계_EQ_이음_사용자_이음(client): + with store.at(layers.personal_dir(7, 42)): + linker = hfix.Linker(by_name=True) + labor = {"갈래": "노임", "이름": "보통인부", "단위": "인"} + price = {"노무비": hfix.Decimal(1), "재료비": hfix.Decimal(0), "경비": hfix.Decimal(0)} + assert linker.link(labor, price)["노임"] == "LB000002" # 단가 달라도 이름으로 + machine = {"갈래": "기계", "이름": "덤프트럭", "규격": "15ton", "단위": "hr"} + code = linker.link(machine, price)["기계"] + assert code and "덤프트럭" in ours.eq_of(_eq_code(code))[0] + line = {"갈래": "자재", "이름": "없는자재", "규격": "", "단위": "개", "수량": 2, + "단가": {"재료비": 10}, "차례": 0} # fmt: skip + one = hfix.line_of(line, [], {"파일": "x", "호표": 1}, set(), linker, {"노임": "LB000002"}) + assert one["고정"]["이음"] == {"노임": "LB000002", "자재": None, "기계": None} + assert hfix.linked(one) and not hfix.linked(hfix.line_of(line, [], {}, set(), linker)) + + +def _eq_code(key: str) -> str: + """EQ 키(EQ000001) → 기계 번호(0101-0007).""" + return next(k for k, v in m01.loaded()[1].numbers["EQ"].items() if v.get("키") == key) diff --git a/resources/tester/test_m02_hopyo_build.py b/resources/tester/test_m02_hopyo_build.py index d6e0bd709..d3f4f6293 100644 --- a/resources/tester/test_m02_hopyo_build.py +++ b/resources/tester/test_m02_hopyo_build.py @@ -19,13 +19,18 @@ DIG = "구조물터파기(인력100%)|토사|m3" FILL = "구조물되메우기(인력100%)||m3" SOIL = "잔토처리(인력100%)||m3" BUILD = "/api/m02/layers/{}/hopyo-build" +LEFT = "/api/m02/layers/{}/hopyo-unlinked" LEDGER = "/api/m02/layers/{}/hopyo-ledger" -def _build(client, no, lines=None, layer="personal", want=200, **extra): +def _build(client, no, lines=None, layer="personal", want=200, decide=True, **extra): + """`decide` = 남은 줄(729-2 · 이음 없는 고정 줄)을 「우리에 없음」으로 고른 뒤 만듦(옛 고정 줄 꼴 그대로).""" body = {"층": "personal", "키": "HP-000001", "번": no, **extra} if lines is not None: body["줄"] = lines + if decide and want == 200 and "통째" not in extra: + left = client.post(LEFT.format(layer), json=body).json().get("남은줄") or [] + body["줄"] = [*(lines or []), *({"차례": x["차례"], "뺌": "우리에 없음"} for x in left)] res = client.post(BUILD.format(layer), json=body) assert res.status_code == want, res.text return res.json() @@ -84,7 +89,9 @@ def test_확인한_줄만_별칭_수량_빠진_줄_장부(client): ] assert doc["자재"] == [] # 표지석 = 몸에 없음 → 고정 줄 assert got["비교"]["엑셀"] == 1415169 and got["비교"]["우리"] > 0 - assert got["다시씀"] is False and got["장부"] == {"씀": 3} + assert got["다시씀"] is False and got["장부"] == { + "씀": 4 + } # + 표지석 「우리에 없음」(729-2 고름) book = _book(mine) assert book[DIG]["갈래"] == "짝" and book[DIG]["짝"] == [ {"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 1, "몫": None} @@ -177,7 +184,8 @@ def test_전국_자재_키_없이_본뜬에(client): } ] assert [m["까닭"] for m in got["빠진줄"] if m["차례"] == 0] == [hb.NATIONAL] - assert got["장부"]["씀"] == 0 + national = hs.read(mine, "HP-000001")["호표"][0]["줄"][0] + assert hb.ledger.key(national) not in _book(mine) # 전국 줄 = 장부 안 씀 _build(client, 1, [{"차례": 1, "대상": [{"전국": True, "배": 1}]}], want=400) # 로직 줄 both = [{"차례": 0, "대상": [{"전국": True, "자재": "MT004258", "배": 1}]}] _build(client, 1, both, want=400) @@ -276,7 +284,8 @@ def test_장부_길_보기_고치기_권한(client): assert _post(client).status_code == 200 _build(client, 3, [{"차례": 1, "대상": [{"로직": "GF000101"}]}]) rows = client.get(LEDGER.format("personal")).json()["줄"] - assert [(r["열쇠"], r["층"], r["고침"]) for r in rows] == [(DIG, "personal", True)] + assert (DIG, "personal", True) in [(r["열쇠"], r["층"], r["고침"]) for r in rows] + assert {r["갈래"] for r in rows if r["열쇠"] != DIG} == {"우리에 없음"} # 729-2 고른 남은 줄 put = { "줄": {DIG: {"갈래": "짝", "짝": [{"로직": "GF000103", "배": 2}]}, SOIL: {"갈래": "안 씀"}} } diff --git a/resources/tester/test_m02_hopyo_flow.py b/resources/tester/test_m02_hopyo_flow.py index cdb995510..d0062d5d6 100644 --- a/resources/tester/test_m02_hopyo_flow.py +++ b/resources/tester/test_m02_hopyo_flow.py @@ -56,8 +56,12 @@ def _find(client, key: str, name: str) -> tuple[int, dict]: raise KeyError(name) -def _build(client, key: str, no: int, lines: list, **extra) -> dict: +def _build(client, key: str, no: int, lines: list, decide: bool = False, **extra) -> dict: + """`decide` = 남은 줄(729-2)을 「우리에 없음」으로 고른 뒤 만듦.""" body = {"층": "personal", "키": key, "번": no, "줄": lines, **extra} + if decide: + left = client.post("/api/m02/layers/personal/hopyo-unlinked", json=body).json()["남은줄"] + body["줄"] = [*lines, *({"차례": x["차례"], "뺌": "우리에 없음"} for x in left)] res = client.post("/api/m02/layers/personal/hopyo-build", json=body) assert res.status_code == 200, res.text return res.json() @@ -127,7 +131,7 @@ def test_섞인_줄_몫_배(client): screen = _node({"real": [line]}) assert screen["lines"][0]["checkable"] and not screen["lines"][0]["checked"] one = {k: part[k] for k in ("로직", "입력값", "배", "몫")} - made = _build(client, "HP-000001", no, [{"차례": line["차례"], "대상": [one]}]) + made = _build(client, "HP-000001", no, [{"차례": line["차례"], "대상": [one]}], decide=True) doc = _doc(made["참조"]) at = next(i for i, x in enumerate(doc["담은로직"]) if x.get("로직") == "GF000211") assert doc["담은로직"][at]["수량"] is None # 723-1 — 수량 저장 안 함 · 엑셀 수량 = 출처 기록 @@ -140,9 +144,13 @@ def test_막는_길(client): "/api/m02/layers/personal/hopyo-build", json={"층": "personal", "키": "HP-000001", "번": 1, "줄": []}, ) - assert ( - empty.status_code == 200 and empty.json()["줄수"]["품셈"] == 0 - ) # 빈 몸 = 모두 고정(705-3) + left = empty.json()["detail"]["남은줄"] # 729-2 — 안 이은 줄 남음 = 안 만듦 + assert empty.status_code == 409 and left + peek = client.post( + "/api/m02/layers/personal/hopyo-unlinked", + json={"층": "personal", "키": "HP-000001", "번": 1, "줄": []}, + ).json() + assert peek == {"남은줄": left, "수": len(left)} money = client.post( "/api/m02/layers/personal/hopyo-build", json={"층": "personal", "키": "HP-000001", "번": 1, "줄": [{"차례": 1, "금액": 1}]},