Files
Aislo/M02_MasterTemplete/M02_Logic_Standard.py
T

361 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""M02 기준 목록 · 계산서 보기(PLAN 704-1 · 704-2 · 요구서 4장 8) — 로직 고르기 창이 읽음.
기준 목록 = 단가산출 로직 하나를 대표 규격으로 펼친 줄(품명 · 규격 · 단위 · 금액 · 출처 · 공종).
대표 규격 = 고르기 입력 값 전부 · 표 찾기 조건으로 쓰이는 수 입력은 그 표 줄마다 값 하나(범위 줄은
위 끝) · 그 밖 연속 값 입력은 견본 자동값(`master_auto.values`) 한 줄 · 지역 = 자동값(전국평균).
펼침 = 바탕(자동값 · 규격 축은 첫 대표 값) 한 줄 + 규격 축마다 값 하나씩 바꾼 줄(곱하지 않음 ·
바꾼 줄이 0 원이면 표에 없는 조합으로 보고 뺌).
금액 = M01 엔진 식 + B09 산근 끝수(줄마다 버림 · `M02_Logic_Settle` · 근거 지식DB
`technical_info/01_임도/05_원가정보/단수처리_규칙.md`) · 최신 판 노임(마스터 판).
목록은 서버가 뒤에서 한 번 셈(프로세스 `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_Logic_Settle as settle
from M02_MasterTemplete import M02_Template_Layers as layers
SHAPE = 3 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀
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("이름"))
out = [w for w, targets in WORDS.items() if any(_sq(t) in name for t in targets)]
if "터파기" in name: # 내역서 낱말 「측구파기」 ↔ 로직 「측구터파기」
out.append(str(row.get("이름")).replace("터파기", "파기"))
return out
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 = settle.settle(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)
filled = m01.cm.mf.fill_empty(logic, given)
got = m01.mtx.lines(files, key, filled)
done = settle.settle(whole, key, filled)
if "결과" not in done: # 돈 로직 — 줄마다 버림(B09 산근 규칙)
settle.apply(got.get("groups") or [], m01.cm.mf.run(whole, key, filled), done)
got["계"] = done["계"]
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("계"),
}
)