"""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 · 기계 = 옵션 거꾸로 찾기) · 남은 우리 = 뺀줄 자재 = 같은 이름 우리 줄 수량 덮개 · 옮기지 못한 차이 = `못옮김`(버리지 않고 본뜬에 적음) 못옮김 = `[{id, 갈래 노임|기계|자재, 이름, 규격, 단위, 수량(엑셀 1 단위 품), 글, 후보}]` · 사용자 `잇기` `[{id, 노임|기계|자재: 키}]` = SK 더한줄 인력 · 기계 · 재료(엑셀 수량 그대로) · `{id, 뺌: 까닭}` = 놓음. SK 단위(728-2) = 엑셀 줄 단위 · 바탕 기준단위와 성격 다름 = 환산 계수 = 우리 품 ÷ 엑셀 품(인력 · 장비 둘 이상이 같은 비일 때만 · `환산` {계수}) · 못 구함 = 고름 대기(까닭 「단위 성격 다름 · 계수 필요」). 고름 대기 = 후보 · 숫자 없음 — 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 import M02_Sangeun_Doc as sd from M02_MasterTemplete.M02_Hopyo_Words import mtypes, specnums BASE, SK, WAIT = "기초", "SK", "고름 대기" KEYS = ("덮개", "자재표시", "더한줄", "뺀줄", "못옮김") TINY = 1e-9 _ADD = re.compile(r"^add:#(\d+)$") UNIT_WHY = "단위 성격 다름 · 계수 필요" LINK = { "노임": ("인력", "LB"), "기계": ("기계", "EQ"), "자재": ("재료", "MT"), } # 잇기 칸 → 더한줄 · 키 머리 class LinkError(ValueError): """잇기 틀림(없는 id · 갈래 다름 · 없는 키) — 부르는 쪽이 400.""" 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.ratio_of(doc, whole.logic(doc["바탕"]["로직"]), whole)) self.lines = parts(doc, whole) self.lost: list[dict[str, Any]] = [] 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 miss(self, kind: str, name: str, qty: float, text: str, **more: Any) -> None: """못옮김 한 줄 — id = 「갈래:이름」(같은 것 = #n) · 수량 = 엑셀 1 단위 품.""" base = f"{kind}:{name}" n = sum(1 for x in self.lost if (x["갈래"], x["이름"]) == (kind, name)) one = {"id": base if not n else f"{base}#{n}", "갈래": kind, "이름": name, "규격": "", "단위": "", "수량": _num(float(f"{qty:.10g}")), "글": text, "후보": []} # fmt: skip self.lost.append({**one, **more}) def link(self, picks: list[dict[str, Any]]) -> None: """사용자 잇기 — 이은 줄 = 더한줄(엑셀 수량 × 배) · 뺌 = 놓음 · 나머지 못옮김 그대로.""" for raw in picks: got = next((x for x in self.lost if x["id"] == str(raw.get("id"))), None) if got is None: raise LinkError(f"없는 못옮김 「{raw.get('id')}」") if raw.get("뺌"): self.lost.remove(got) self.doc.setdefault("놓음", []).append({**got, "뺌": str(raw["뺌"])}) continue kind, head = LINK.get(got["갈래"], ("", "")) key = str(raw.get(got["갈래"]) or "") if not kind or not key.startswith(head): raise LinkError(f"「{got['id']}」 · {got['갈래']} 키({head}…)로 이음") try: elem = self.whole.get(key) or {} except Exception as e: # noqa: BLE001 — 없는 키 raise LinkError(f"「{got['id']}」 · 없는 키 「{key}」") from e self.lost.remove(got) name = str(elem.get("이름") or got["이름"]) unit = got["단위"] or ("인" if kind == "인력" else str(elem.get("단위") or "")) self.add({"종류": kind, "요소": key, "이름": name, "단위": unit, "수량": _num(float(got["수량"]) * self.tb)}) # fmt: skip 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.miss("노임", t, a, f"엑셀 {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.miss("노임", t, want, f"엑셀 {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"]) for a, q in fixed: self.miss("기계", "·".join(sorted(a)), q, "우리만 장비 — 더한 기초 안", 단위="시간") 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 spec = " ".join(f"{x:g}" for x in m.get("규격") or []) name = "·".join(m["갈래"]) self.miss("기계", name, m["시간"], "엑셀만 장비 — 기계 못 찾음", 규격=spec, 단위="시간") 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.miss("자재", m["이름"], m["품"], "엑셀만 자재") 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 _need(code: str, unit: str, whole: Any) -> dict[str, Any] | None: """후보 기초 기준단위 ↔ 엑셀 단위가 성격 다름 = 계수 칸 `{가, 나, 갈래 사용자|단위중량}` · 아니면 None.""" from M01_MasterData import M01_MasterData_Store_Combo as combo_store try: got = calc.base_unit(whole.logic(code)) except Exception: # noqa: BLE001 — 없는 로직 return None if not got or str(unit or "").strip() in ("", "식"): return None swap = combo_store.mu.swap(got[0], unit) return None if swap["배"] is not None else {"가": got[0], "나": unit, "갈래": swap["계수"]} def _wait(entry: dict, whole: Any, unit: str, why: str | None = None) -> dict[str, Any]: keep = ("로직", "이름", "단위", "판정", "글") cands = [{k: c.get(k) for k in keep} for c in (entry.get("후보") or [])[: fit.TOP_OPT]] for c in cands: c["계수"] = _need(str(c.get("로직") or ""), unit, whole) if why is None: why = "맞는 기초 없음 — " + ((entry.get("판정") or {}).get("글") or "후보 없음") return {"갈래": WAIT, "까닭": why, "후보": cands} def _by_type(rows: list[dict[str, Any]]) -> 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 _coef(doc: dict, whole: Any, excel: dict[str, Any]) -> float | None: """성격 다른 단위 계수(바탕 앞 수 뗀 1 단위 = SK 몇 단위) = 우리 품 ÷ 엑셀 품 — 인력 직종 · 장비 갈래 둘 이상이 같은 비(맞춤 허용 폭)일 때만 · 아니면 None(고름 대기).""" mine = measure({**doc, "단위": ""}, whole) # 「」 = 앞 수 뗀 바탕 단위(옛 셈) pairs = [(mine["인력"].get(t, 0.0), q) for t, q in excel["인력"].items()] ours_m = _by_type(mine["장비"]) pairs += [(ours_m.get(k, 0.0), q) for k, q in _by_type(excel["장비"]).items()] rates = [a / b for a, b in pairs if a > TINY and b > TINY] if len(rates) < 2 or max(rates) > min(rates) * (1 + fit.TOL[0]): return None return float(f"{sum(rates) / len(rates):.6g}") def plan( entry: dict | None, block: dict | None, line: dict[str, Any], whole: Any, pick: dict | None = None, ) -> dict[str, Any]: """블록 한 칸 → `{갈래 기초|SK|고름 대기, 바탕, 입력값, 환산?, 덮개, 자재표시, 더한줄, 뺀줄, 못옮김, 놓음, 엑셀품, 후보?}` · `pick` = 사용자 `{로직?, 입력값?, 환산?, 잇기?}` — 로직 = 고른 기초(옵션 없으면 거꾸로 찾음 · 늘 견줌) · 환산 = SK `환산` 꼴 · 잇기 = 못옮김 잇기(`_Plan.link` · 틀림 `LinkError`).""" entry, pick = entry or {}, pick or {} state = (entry.get("판정") or {}).get("상태") efp = finger.excel(block, line) unit = str(line.get("단위") or "").strip() or "식" if pick.get("로직"): 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, whole, unit) doc = {"키": "SK-000000", "이름": "견줌", "단위": unit, "바탕": {"로직": code}, "입력값": dict(given), "덮개": {}, "뺀줄": [], "자재표시": {}} # fmt: skip if pick.get("환산"): doc["환산"] = dict(pick["환산"]) from M02_MasterTemplete import M02_Hopyo_Verdict as verdict # 판정이 Fit 을 부름 — 늦게 읽음 excel = verdict.excel_qty(efp) row = whole.logic(code) def extras(tb: float) -> list[dict[str, Any]]: return [] if pick.get("로직") else _extras(entry, code, whole, tb) try: tb = float(calc.ratio_of(doc, row, whole)) except sd.DocError as e: old = {**doc, "더한줄": extras(float(calc.times_of(row)))} coef = None if pick.get("환산") else _coef(old, whole, excel) if coef is None: cands = entry.get("후보") or [] if code not in [c.get("로직") for c in cands]: cands = [{"로직": code, "이름": row.get("이름")}, *cands] why = f"{UNIT_WHY} — {str(e).split(' · ', 1)[-1]}" return _wait({"후보": cands}, whole, unit, why) doc["환산"] = {"계수": coef} tb = float(calc.ratio_of(doc, row, whole)) doc["더한줄"] = extras(tb) head = {"바탕": doc["바탕"], "입력값": doc["입력값"], "엑셀품": excel} head |= {"환산": doc["환산"]} if doc.get("환산") else {} if state == "맞음" and not pick and not doc["더한줄"]: base = {**dict.fromkeys(KEYS[:2], {}), **dict.fromkeys(KEYS[2:], []), "놓음": []} return {**head, "갈래": BASE, **base} 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("품")]) work.link(pick.get("잇기") or []) out = {k: doc[k] for k in KEYS[:4]} changed = any(out.values()) or bool(work.lost) kept = {"못옮김": work.lost, "놓음": doc.get("놓음") or []} return {**head, **out, "갈래": SK if changed else BASE, **kept}