"""Z01 기초단가 다섯 — 노임·기계·자재·유가·요율을 kind 마다 **한 표**로(2026-09-15 사용자 지시 · 브레인 계약). 표 수준(한 번): columns · total · editable · locked{열: 왜 못 고치나} · formula{열: 식} · notice(요율만) 줄 수준(해당할 때만): `@id`(자료 제 열쇠) · `@overrides`(고친 줄) · `@formula`(계산값 줄) `@id` — 줄 차례가 아니라 자료 열쇠(브레인 승인): 노임 `{dataset_id}/{occupation_code}` · 기계 machine_code · 자재 item_code · 유가 `{scope}/{변수}[/{sido_code}]` · 요율 `{변수}[/{목록}/{구간칸=값;…}]`(구간 칸 이름 차례 · 값·식 칸 뺌) 원본 파일 자리는 이름표 파일이 가리키는 곳(`Z01_MasterData_Tables`) — 원본은 읽기만, 고친 값은 덮개(`Z01_MasterData_Overrides`). """ from __future__ import annotations import json from decimal import Decimal from functools import lru_cache from pathlib import Path from typing import Any from Z01_MasterData import Z01_MasterData_BasePrices_Machine as machine from Z01_MasterData import Z01_MasterData_Overrides as overrides from Z01_MasterData import Z01_MasterData_Tables as tables KINDS = ("labor", "machine", "material", "oil", "rate") _KEY = "자료 열쇠 — 바꾸면 덮개·다른 표가 이 줄을 못 찾음" _SOURCE = "공표 원문 칸 — 원본 갱신으로만 바뀜" _RATE_EDITABLE = ( "rate_percent", "base_amount_krw", "minimum_estimated_amount_krw", "minimum_total_construction_amount_krw", "rate_from_2033_percent", "manager_thresholds.default_estimated_amount_krw", "manager_thresholds.civil_main_work_estimated_amount_krw", ) _RATE_NOTICE = "법이 정한 값 — 고치면 원본 갱신 때 구간 이름이 바뀌어 고친 값이 주인을 잃기 쉬움(주인 없음으로 남음)" def _source(file_id: str) -> Path: labels = tables.load_labels() entry = next((f for f in labels["files"] if f["file_id"] == file_id), None) path = tables._file_path(entry) if entry else None if path is None: raise FileNotFoundError(f"이름표에 원본 자리가 없음: {file_id}") return path @lru_cache(maxsize=16) def _read(path: str, _mtime_ns: int) -> dict[str, Any]: return json.loads(Path(path).read_text(encoding="utf-8")) def _doc(file_id: str) -> tuple[dict[str, Any], str]: path = _source(file_id) return _read(str(path), path.stat().st_mtime_ns), path.name def _records(doc: dict[str, Any]) -> list[dict[str, Any]]: return next( v["records"] for v in doc["variables"].values() if isinstance(v.get("records"), list) ) def _labor() -> list[dict[str, Any]]: rows = [] for file_id in ("labor_const", "labor_mfg"): doc, name = _doc(file_id) dataset = doc.get("dataset_id") or file_id for r in _records(doc): # 기준일 — 두 판(건설 · 제조) 기준일이 달라 한 표에 섞임 → 칸으로 드러냄 row = { "@id": f"{dataset}/{r['occupation_code']}", "@source": name, "dataset_id": dataset, "effective_date": doc.get("effective_date"), **r, } row.setdefault("daily_wage_krw", None) # 미공표 직종 — 사람이 넣을 수 있음 rows.append(row) return rows def _material() -> list[dict[str, Any]]: doc, name = _doc("mat_price_public") date = doc.get("effective_date") return [ {"@id": str(r["item_code"]), "@source": name, "effective_date": date, **r} for r in _records(doc) ] def _oil() -> list[dict[str, Any]]: rows = [] for file_id in ("oil", "oil_regional"): doc, name = _doc(file_id) for variable, v in doc["variables"].items(): head = {k: x for k, x in v.items() if k not in ("records", "value")} common = { "scope": v.get("scope"), "fuel": variable, "effective_date": doc.get("effective_date"), # 전국 · 지역 판 기준일이 다름 **head, } if "records" not in v: rows.append( { "@id": f"{v.get('scope')}/{variable}", "@source": name, **common, "value": v.get("value"), } ) for r in v.get("records") or []: rows.append( { "@id": f"{v.get('scope')}/{variable}/{r['sido_code']}", "@source": name, **common, **r, } ) return rows def _flat(d: dict[str, Any], prefix: str = "") -> dict[str, Any]: out: dict[str, Any] = {} for k, v in d.items(): if isinstance(v, dict): out.update(_flat(v, f"{prefix}{k}.")) elif not isinstance(v, list): out[f"{prefix}{k}"] = v return out def _rate() -> list[dict[str, Any]]: doc, name = _doc("rates") rows = [] for variable, v in doc["variables"].items(): rows.append( { "@id": variable, "@source": name, "variable": variable, "part": "", "effective_date": doc.get("effective_date"), **_flat(v), } ) for part, items in v.items(): if not (isinstance(items, list) and items and all(isinstance(x, dict) for x in items)): continue for item in items: bracket = ";".join( f"{k}={item[k]}" for k in sorted(item) if k not in _RATE_EDITABLE and k != "formula" and not isinstance(item[k], (dict, list)) ) rows.append( { "@id": f"{variable}/{part}/{bracket}", "@source": name, "variable": variable, "part": part, "effective_date": doc.get("effective_date"), "base": v.get("base"), **item, } ) return rows _BUILDERS = {"labor": _labor, "material": _material, "oil": _oil, "rate": _rate} def base_rows(kind: str) -> list[dict[str, Any]]: """덮개 얹기 전 원본 줄 — 기계는 입력 칸까지(계산 칸은 덮개를 얹은 뒤 셈).""" if kind == "machine": doc, name = _doc("mach_base") return machine.base_rows(name, doc.get("effective_date")) return _BUILDERS[kind]() def _machine_inputs() -> machine.Inputs: """기계가 끌어 쓰는 밑값 — **덮개를 얹은** 노임(건설)·전국평균 유가 + 그 판들의 기준일.""" labor = [r for r in overrides.overlay("labor", _labor()) if r["dataset_id"] == "labor_const"] oil = [ r for r in overrides.overlay("oil", _oil()) if r["@id"] == f"national_average/{r['fuel']}" ] return machine.Inputs( wages={ str(r["occupation_code"]): None if r["daily_wage_krw"] is None else Decimal(str(r["daily_wage_krw"])) for r in labor }, wage_date=labor[0]["effective_date"] if labor else None, oil={r["fuel"]: Decimal(str(r["value"])) for r in oil}, oil_dates={r["fuel"]: r.get("date") or r["effective_date"] for r in oil}, operating_date=_doc("pum_const")[0].get("effective_date"), ) def rows(kind: str) -> list[dict[str, Any]]: """덮개를 얹은 줄 — 기계는 그 위에서 시간당 단가를 다시 셈.""" out = overrides.overlay(kind, base_rows(kind)) if kind == "machine": inputs = _machine_inputs() out = [machine.compute(r, inputs) for r in out] return out def _columns(kind: str, all_rows: list[dict[str, Any]]) -> list[str]: keys: dict[str, None] = {} for r in all_rows: keys.update(dict.fromkeys(k for k in r if not k.startswith("@"))) return list(keys) def spec(kind: str, columns: list[str]) -> dict[str, Any]: """표 수준 — editable · locked · formula (· notice).""" if kind == "machine": return machine.spec(columns) editable = { "labor": ["daily_wage_krw"], "material": ["price_krw"], "oil": ["value"], "rate": [c for c in _RATE_EDITABLE if c in columns], }[kind] keys = {"labor": {"dataset_id", "occupation_code"}, "material": {"item_code"}}.get(kind, set()) if kind == "oil": keys = {"scope", "fuel", "sido_code"} if kind == "rate": locked = { c: "규칙·구간 칸(로직) — 원본 갱신으로만 바뀜" for c in columns if c not in editable } else: locked = {c: _KEY if c in keys else _SOURCE for c in columns if c not in editable} out: dict[str, Any] = {"editable": editable, "locked": locked, "formula": {}} if kind == "rate": out["notice"] = _RATE_NOTICE return out def row_label(kind: str, row: dict[str, Any] | None) -> str: if row is None: return "" if kind == "labor": return str(row.get("occupation_name") or "") if kind == "machine": return f"{row.get('machine_name') or ''} {row.get('specification') or ''}".strip() if kind == "material": return str(row.get("specification") or row.get("classification_name") or "") if kind == "oil": return f"{row.get('source_product_name') or row.get('fuel')} {row.get('sido_name') or '전국평균'}" named = (tables.load_labels().get("value_overrides") or {}).get( f"rates::variables/{row['variable']}" ) or {} bracket = row["@id"].split("/", 2)[2].replace(";", " · ") if row["part"] else "" return f"{named.get('name_ko') or row['variable']} {bracket}".strip() def column_meta(kind: str, key: str) -> dict[str, Any]: """열 이름 — 이름표 `synthetic_tables['@base_prices/{kind}']` → `columns[열key]` → 영문 key.""" labels = tables.load_labels() own = (labels.get("synthetic_tables") or {}).get(f"@base_prices/{kind}") or {} named = next((c for c in own.get("columns") or [] if c.get("key") == key), None) named = named or labels["columns"].get(key) or {} return { "key": key, "label": named.get("name_ko") or key, "unit": named.get("unit") or "", "hidden": named.get("visible") is False, } def public(row: dict[str, Any]) -> dict[str, Any]: """내보낼 줄 — 안쪽 칸(`@source`)은 뺌.""" return {k: v for k, v in row.items() if k != "@source"} def table( kind: str, page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE, q: str = "" ) -> dict[str, Any]: all_rows = rows(kind) columns = _columns(kind, all_rows) needle = q.strip().lower() hits = ( [ r for r in all_rows if needle in json.dumps(public(r), ensure_ascii=False, default=str).lower() ] if needle else all_rows ) size = max(1, min(size, tables.MAX_PAGE_SIZE)) start = (max(page, 1) - 1) * size return { "columns": [column_meta(kind, c) for c in columns], "rows": [public(r) for r in hits[start : start + size]], "total": len(hits), **spec(kind, columns), } def edit(kind: str, row_id: str, values: dict[str, Any], by: Any) -> tuple[int, dict[str, Any]]: """고치기 — (상태 코드, 고친 뒤 그 줄 또는 막은 까닭).""" base = next((r for r in base_rows(kind) if r["@id"] == row_id), None) if base is None: return 404, {"message": f"없는 줄: {row_id}"} info = spec(kind, _columns(kind, rows(kind))) for column, value in values.items(): if column not in info["editable"] or column not in base: reason = info["locked"].get(column) or "이 줄에 없는 칸" return 400, {"message": f"못 고치는 칸 {column} — {reason}"} problem = overrides.check_value(value) if problem: return 400, {"message": f"{column}: {problem}"} overrides.write(kind, base, values, by) return 200, public(next(r for r in rows(kind) if r["@id"] == row_id)) def override_items( kind: str = "", state: str = "", page: int = 1, size: int = tables.DEFAULT_PAGE_SIZE ) -> dict[str, Any]: """모아 보기 — 서버가 정렬(원본 바뀜 → 주인 없음 → 고쳐짐) · 거름(kind·state) · 쪽 · kind 별 state 수.""" out = [] counts: dict[str, dict[str, int]] = {} for each in KINDS: # 수는 늘 다섯 다 셈 — 거름은 줄에만 base = base_rows(each) by_id = {r["@id"]: r for r in base} for item in overrides.items(each, base): counts.setdefault(each, {}).setdefault(item["state"], 0) counts[each][item["state"]] += 1 if (kind and each != kind) or (state and item["state"] != state): continue item["row_label"] = row_label(each, by_id.get(item["row_id"])) item["column_label"] = column_meta(each, item["column"])["label"] out.append(item) out.sort(key=lambda i: overrides.STATE_ORDER.index(i["state"])) size = max(1, min(size, tables.MAX_PAGE_SIZE)) start = (max(page, 1) - 1) * size return {"items": out[start : start + size], "total": len(out), "counts": counts} def clear_overrides(kind: str, state: str) -> int: return overrides.clear(kind, state, base_rows(kind))