- `M02_Logic_Standard` — 로직 하나를 대표 규격(고르기 값 전부 · 표 찾기 조건 수 = 표 줄마다 · 지역 = 자동값)으로 펼친 줄 · 품명 · 규격 · 단위 · 금액(엔진 계) · 출처 · 공종 · 실무 낱말 별칭(석축 → 돌쌓기) - 목록 = 서버가 뒤에서 한 번 셈(프로세스 4 · 약 90 s) → storage `_derived/m02_logic_standard/` · 열쇠 = 마스터 내용 지문 · 셈 도중 빈 줄 + 준비 false - 계산서 = `master_text.lines` 줄 · 비목 소계 · 계 · 맨 위 출처 · 노임 판 · 규격 축 아닌 입력 400 - 길 `/api/m02/logic-lib/standard` · `/logic-lib/sheet` — 로그인만(일반 사용자도) - 시험 `test_704_logic_standard.py` — 세 항목 금액 = M01 시험 계산 · 계산서 계 = 엔진 값 · 석축 찾기 · 저장본 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
351 lines
14 KiB
Python
351 lines
14 KiB
Python
"""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("계"),
|
||
}
|
||
)
|