"""M02 기준 목록 · 계산서 보기(PLAN 704-1 · 704-2 · 요구서 4장 8) — 로직 고르기 창이 읽음. 기준 목록 = 단가산출 로직 하나를 대표 규격으로 펼친 줄(품명 · 규격 · 단위 · 금액 · 출처 · 공종). 대표 규격 = 고르기 입력 값 전부 · 표 찾기 조건으로 쓰이는 수 입력은 그 표 줄마다 값 하나(범위 줄은 위 끝) · 그 밖 연속 값 입력은 견본 자동값(`master_auto.values`) 한 줄 · 지역 = 자동값(전국평균). 펼침 = 바탕(자동값 · 규격 축은 첫 대표 값) 한 줄 + 규격 축마다 값 하나씩 바꾼 줄(곱하지 않음 · 바꾼 줄이 0 원이면 표에 없는 조합으로 보고 뺌). 금액 = M01 엔진(`master_formula.run`) 「계」 — M01 시험 계산과 같은 셈 · 최신 판 노임(마스터 판). 목록은 서버가 뒤에서 한 번 셈(프로세스 `WORKERS` 개 · 수 분) → 프로세스 메모리 + 파생 저장본 (storage `_derived/m02_logic_standard/`) · 열쇠 = 마스터 내용 지문 + `SHAPE` · 같으면 안 셈 · 셈 도중 목록 = 빈 줄 `준비: false`(화면은 로직 줄 그대로 · 금액 빈칸). 실무 낱말(`WORDS`) = 별칭 칸에 덧붙여 찾기(공용 `matchLogic`)가 「석축」 → 돌쌓기를 찾음. 계산서 = `master_text.lines`(줄 「석공 | 268,908 * 0.1 = 26,891」 · 비목 소계 · 계 = 엔진 값). M01 은 읽기만 · 권한은 부르는 쪽 몫(로그인). """ from __future__ import annotations import hashlib import os import sys import threading from concurrent.futures import ProcessPoolExecutor from decimal import Decimal from typing import Any from common_util.common_util_derived_store import derived_key, read_derived, write_derived from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Template_Layers as layers SHAPE = 2 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀 KIND = "m02_logic_standard" REGION = "지역" # 지역 = 자동값 한 벌(정함 8) QUIET = ("해당 없음", "없음") # 규격 글에서 뺄 값 #: 실무 낱말 → 로직 이름에 든 품셈 낱말(띄어쓰기 무시) — 맞으면 별칭으로 붙음 WORDS: dict[str, tuple[str, ...]] = { "석축": ("돌쌓기", "돌붙임", "찰쌓기", "메쌓기"), "기슭막이": ("돌쌓기", "돌붙임"), "골막이": ("돌쌓기",), "흄관": ("원심력",), "배수관": ("흄관", "파형강관", "원심력", "관부설"), "땅파기": ("터파기", "굴착"), "흙메우기": ("되메",), "콘크리트치기": ("타설",), "레미콘": ("타설",), "비탈녹화": ("녹화", "파종", "식생"), } #: 공종 — 차례 = 거름 차례(임도 · 사방 자주 쓰는 것 먼저) · (이름 낱말, 장 낱말) · 이름이 먼저 KINDS: tuple[tuple[str, tuple[str, ...], tuple[str, ...]], ...] = ( ("돌공", ("돌쌓기", "돌붙임", "석축", "찰쌓기", "메쌓기", "전석", "사석", "돌망태", "개비온"), ("돌공사",)), ("관", ("흄관", "강관", "파형", "관부설", "배수관", "PE관", "관접합"), ("관부설",)), ("콘크리트", ("콘크리트", "거푸집", "철근", "타설", "레미콘", "모르타르"), ("철근콘크리트",)), ("토공", ("터파기", "되메", "흙", "성토", "절토", "굴착", "땅깎기", "사토", "다짐", "잔토"), ("토공",)), ("운반", ("운반", "집재", "운재"), ("운반", "집재")), ("식재 · 녹화", ("식재", "녹화", "파종", "식생", "묘목"), ("식재", "생육")), ("벌채", ("벌채", "벌목"), ("나무베기",)), ("가설", (), ("가설",)), ) # fmt: skip OTHER = "기타" WORKERS = max(1, min(4, (os.cpu_count() or 2) - 1)) _LOCK = threading.Lock() _MEMO: dict[str, Any] = {} # whole(색인) → 열쇠 · 목록 · 셈 중 def _sq(text: Any) -> str: return "".join(str(text or "").split()).lower() def kind_of(row: dict) -> str: name, chapter = _sq(row.get("이름")), _sq(row.get("상세구분")) for kind, words, _ in KINDS: if any(_sq(w) in name for w in words): return kind for kind, _, chapters in KINDS: if any(_sq(w) in chapter for w in chapters): return kind return OTHER def words_of(row: dict) -> list[str]: name = _sq(row.get("이름")) return [w for w, targets in WORDS.items() if any(_sq(t) in name for t in targets)] def _range_label(table: dict, low: Any, high: Any, unit: str) -> str: f = m01.mtx.fmt if low is None: return f"{f(high)}{unit} 이하" if high is None: rule = str(table.get("범위규칙") or "초과") return f"{f(low)}{unit} {'이상' if rule.startswith('이상') else '초과'}" return f"{f(low)}~{f(high)}{unit}" def _table_reps(whole: Any, ref: str, cond: str, unit: str) -> list[tuple[Any, str]]: """표 `ref` 조건 칸 `cond` 줄마다 (값, 글) — 범위 줄은 위 끝(없으면 아래 끝 · 드는 값).""" try: table = whole.get(ref) except m01.cm.mf.FormulaError: return [] ranged = table.get("조건", {}).get(cond) == "범위" out: dict[Any, str] = {} for r in table.get("줄", []): have = r.get(cond) if have is None: continue if not ranged: if isinstance(have, Decimal): out.setdefault(have, f"{m01.mtx.fmt(have)}{unit}") continue low, high = have tries = [v for v in (high, low, (low or 0) + 1) if isinstance(v, Decimal)] got = next((v for v in tries if m01.cm.mf._match(table, r, cond, v)), None) if got is not None: out.setdefault(got, _range_label(table, low, high, unit)) return sorted(out.items()) def axes(whole: Any, logic: dict) -> tuple[dict, list[tuple[str, list[tuple[Any, str]]]]]: """(바탕 입력, [(입력 이름, [(값, 글) …]) …]) — 규격 축 = 지역 뺀 고르기 · 표 찾기 수 입력.""" base = m01.mau.values(whole, logic) finds, _ = m01.mau._uses(logic) out = [] for spec in logic.get("입력") or []: name = spec["이름"] if name == REGION: continue unit = str(spec.get("단위") or "") if spec.get("고르기"): labels = labor_inputs._labels(whole, spec) reps = [(v, str(labels.get(v, m01.mtx.fmt(v) if isinstance(v, Decimal) else v))) for v in spec["고르기"]] # fmt: skip elif name in finds: seen: dict[Any, str] = {} for ref, cond in finds[name]: for v, text in _table_reps(whole, ref, cond, unit): seen.setdefault(v, text) reps = sorted(seen.items()) else: continue if not reps: continue if base.get(name) not in [v for v, _ in reps]: base[name] = reps[0][0] out.append((name, reps)) return base, out def _spec(given: dict, ax: list) -> str: parts = [] for name, reps in ax: text = dict(reps).get(given.get(name), "") if text and text not in QUIET: parts.append(text if text.startswith(name) else f"{name} {text}") return " · ".join(parts) def _unit(logic: dict) -> str: unit = str(logic.get("결과단위") or "") return unit[2:] if unit.startswith("원/") else ("" if unit == "원" else unit) def _money(whole: Any, logic: dict, given: dict) -> Decimal | None: if not str(logic.get("결과단위") or "").startswith("원"): return None try: got = m01.cm.mf.run(whole, str(logic["키"]), m01.cm.mf.fill_empty(logic, given)) except (m01.cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): return None return got.get("계", got.get("결과")) def _rows_of(whole: Any, logic: dict, blocked: bool) -> list[dict]: base, ax = axes(whole, logic) changes: list[dict] = [{}] for name, reps in ax: changes += [{name: v} for v, _ in reps if v != base.get(name)] head = { "키": logic.get("키"), "이름": logic.get("이름") or "", "단위": _unit(logic), "출처": logic.get("출처") or "", "공종": kind_of(logic), "별칭": ", ".join(x for x in [logic.get("별칭") or "", *words_of(logic)] if x), "구분": logic.get("구분") or "", "상세구분": logic.get("상세구분") or "", "막힘": blocked, } out = [] for change in changes: given = {**base, **change} money = _money(whole, logic, given) if change and money == 0: # 표에 없는 규격 조합(견치돌 · 뒷길이 25 따위) — 0 원 줄은 뺌 continue out.append( { **head, "규격": _spec(given, ax), "금액": None if money is None else round(float(money), 2), "바꿈": labor_inputs._num(change), } ) return out def edition(files: dict) -> str: """노임 판 글 — 「2026 상반기 노임」(인력 머리 첫 판) · 없으면 빈 글.""" for head in ((files.get("인력.json") or {}).get("구분") or {}).values(): day = str(head.get("판") or "") if len(day) >= 7: return f"{day[:4]} {'상반기' if int(day[5:7]) < 7 else '하반기'} 노임" return "" def _stamp() -> str: """마스터 파일 내용 지문 — 워크트리마다 시각이 달라도 같은 내용이면 같은 열쇠.""" sha = hashlib.sha1() for path in sorted(m01.FOLDER.glob("*.json")): if not path.name.startswith("_"): sha.update(path.name.encode()) sha.update(hashlib.sha1(path.read_bytes()).digest()) return sha.hexdigest() def _chunk(keys: list[str], blocked: set[str]) -> list[dict]: """일꾼 프로세스 몫 — 로직 키들의 줄(마스터는 그 프로세스가 읽음).""" whole = m01.loaded()[1] out = [] for key in keys: out += _rows_of(whole, whole.logic(key), key in blocked) return out def build(files: dict, workers: int = 1) -> dict: """기준 목록 통째 — 장 차례 · `workers` > 1 이면 로직을 나눠 프로세스 여럿이 셈(차례 그대로).""" why_of = m01.blocks() blocked = {k for k, why in why_of.items() if why} keys = [ str(logic.get("키")) for _, data in sorted(m01._logic_files(files), key=lambda f: f[1].get("차례") or 0) for logic in data.get("줄") or [] ] size = max(1, len(keys) // (workers * 8)) parts = [keys[i : i + size] for i in range(0, len(keys), size)] if workers > 1: with ProcessPoolExecutor(workers) as pool: got = list(pool.map(_chunk, parts, [blocked] * len(parts))) else: got = [_chunk(part, blocked) for part in parts] kinds = [k for k, _, _ in KINDS] + [OTHER] return {"판": edition(files), "공종": kinds, "줄": [row for part in got for row in part]} def _make(files: dict, whole: Any, key: str) -> None: try: value = build(files, WORKERS) write_derived(layers.storage_root(), KIND, key, value) with _LOCK: if _MEMO.get("whole") is whole: _MEMO.update(value=value) except Exception as e: # noqa: BLE001 — 다음 요청이 다시 셈 print(f"[M02 기준 목록] 셈 실패: {e}", file=sys.stderr) finally: with _LOCK: if _MEMO.get("whole") is whole: _MEMO["job"] = None def listing() -> dict: """기준 목록 `{판, 공종, 줄, 준비}` — 메모리 → 파생 저장본 → 뒤에서 셈(그동안 빈 줄).""" files, whole = m01.loaded() with _LOCK: if _MEMO.get("whole") is not whole: _MEMO.clear() _MEMO.update(whole=whole, key=derived_key(KIND, SHAPE, _stamp()), value=None, job=None) if _MEMO["value"] is None and _MEMO["job"] is None: got = read_derived(layers.storage_root(), KIND, _MEMO["key"]) if got: _MEMO["value"] = got["value"] else: _MEMO["job"] = threading.Thread( target=_make, args=(files, whole, _MEMO["key"]), daemon=True ) _MEMO["job"].start() if _MEMO["value"] is not None: return {**_MEMO["value"], "준비": True} kinds = [k for k, _, _ in KINDS] + [OTHER] return {"판": edition(files), "공종": kinds, "줄": [], "준비": False} def warm() -> None: """서버가 뜰 때 뒤에서 — 저장본이 있으면 읽기만 · 없으면 셈 시작.""" threading.Thread(target=lambda: _quiet(listing), daemon=True).start() def _quiet(fn) -> None: try: fn() except Exception as e: # noqa: BLE001 — 데움 실패는 첫 요청이 다시 셈 print(f"[M02 기준 목록] 데움 실패: {e}", file=sys.stderr) def _given(whole: Any, logic: dict, change: dict) -> tuple[dict, list]: base, ax = axes(whole, logic) known = {name: dict(reps) for name, reps in ax} for name, value in (change or {}).items(): if name not in known: raise m01.StoreError(400, f"규격 축이 아닌 입력 「{name}」") want = Decimal(str(value)) if isinstance(value, (int, float)) else value if want not in known[name]: raise m01.StoreError(400, f"「{name}」 대표 값이 아님: {value}") base[name] = want return base, ax def sheet(key: str, change: dict) -> dict: """계산서 한 장 — 머리(품명 · 규격 · 단위 · 출처 · 판) + 비목 묶음 줄 · 소계 · 계(엔진 값).""" files, whole = m01.loaded() try: logic = whole.logic(key) except m01.cm.mf.FormulaError as e: raise m01.StoreError(404, str(e)) from e given, ax = _given(whole, logic, change) got = m01.mtx.lines(files, key, m01.cm.mf.fill_empty(logic, given)) groups = [ { "비목": g["비목"], "소계": g.get("소계"), "끝수": g.get("끝수") or "", "줄": [{k: line.get(k) for k in ("이름", "글", "금액")} for line in g["줄"]], } for g in got.get("groups") or [] ] return labor_inputs._num( { "키": key, "이름": logic.get("이름") or "", "규격": _spec(given, ax), "단위": _unit(logic), "출처": labor_inputs.sources(logic) or "", "판": edition(files), "입력": given, "묶음": groups, "계": got.get("계"), } )