일감 4·5 의 엔진 덩어리. PLAN 8-11·8-3·8-7. 프로젝트 설정 (common_util/common_util_project_settings.py) 자리 = <project_root>/project_settings.json, 루트의 project_manifest.json 옆. 매니페스트 stages 는 단계 산출물 목록이고 설정은 두 페이지가 함께 읽는 프로젝트 값이라 단계 폴더에 넣으면 주인이 애매해짐. 구획 quantity(B08) · estimation(B09) — 남의 구획은 읽기만. ⚠ 경계를 코드로 막음 — save_section() 이 이름 붙은 한 구획만 갈아 끼우고 통째로 쓰는 함수를 두지 않음. 두 페이지가 같은 파일을 쓰므로 통째로 덮으면 상대 값이 사라짐(오늘 main.py 에서 같은 모양의 사고를 이미 겪음). ⚠ *_override 기본은 None — 「안 정했으면 config 정본을 씀」. 기본값을 복사해 넣으면 정본이 바뀌어도 옛 프로젝트가 안 따라옴. ⚠ dataset_versions 는 기록이지 정본이 아님 — 되살릴 때 쓰는 정본은 프로젝트 스냅샷이고 어긋나면 스냅샷이 이김. estimation.rate_dataset 은 「연도」가 아니라 판을 가리킴 — 제비율은 연중에도 개정됨(현행판 2026-04-13). B09 확정 반영. 토공집계표 (열 = 구분·공종·규격·단위·계·비고, 거창 실무 시트 그대로) ⚠ 암 갈래 개수를 코드에 안 박음 — 울진 2 · 거창 5 · BOM 1 로 공사마다 다름. 프로젝트 설정의 세트를 받아 그만큼 줄을 냄. 비율이 아직 없으면 지어내지 않고 「암」 한 줄로 냄. 비율 합이 100 이 아니어도 준 비율끼리 안분해 총량 보존. ⚠ 반영률 기본 100 % — 실무 관측 80/50/80 은 참고이지 기본값 아님(법대로). 바꾼 경우에만 비고에 「80 % 반영」이 남음 — 누가 정한 값인지 보이게. 운반 가중평균 ((운반수단 x 지반유형)별 1줄) 실무 울진이 도자 토사/암 · 덤프 토사/암 4줄을 올림. 가중평균 = Σ(토량x거리)/Σ토량 으로 단순평균이 아님. 개별 구간은 근거로 함께 냄 — 어느 구간이 그 평균을 만들었는지 되짚게. 입력은 이미 있는 HaulPlan — 띠·장거리이동에 equipment·haul_distance_m· 지반유형별 물량(ea/rr/br)이 다 있어 다시 세지 않음. ⚠ 무대(free_haul)는 in_bill=False — 품셈 1-2-7 로 품에 포함이고 인력운반 10-6 도 「소운반 20m 초과분」이라 붙일 단가가 없음. 값은 내되 내역 줄만 뺌 — 「무대+도자+덤프 = 총 운반토량」 검산에 씀. 검증 — 28건 통과 (토공집계·설정 19 + 운반 9). 가중평균이 단순평균과 다름을 못 박음(도자 토사 600@40+400@50 → 44m, 45 아님). 울진2·거창5 두 세트가 같은 코드로 서는 것, 무대가 내역 줄이 아닌 것, 구획을 따로 저장해도 상대 값이 안 지워지는 것을 각각 시험함. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
159 lines
7.1 KiB
Python
159 lines
7.1 KiB
Python
"""프로젝트 설정 — 수량(B08)·원가(B09) 두 페이지가 함께 읽는 값 (PLAN 8-7).
|
|
|
|
자리
|
|
`<project_root>/project_settings.json` — 루트, `project_manifest.json` 옆.
|
|
매니페스트의 `stages` 는 **단계 산출물** 목록이고, 설정은 산출물이 아니라 **프로젝트 값**이다.
|
|
단계 폴더에 넣으면 주인이 애매해진다.
|
|
|
|
구획 — 페이지마다 자기 것만 쓴다
|
|
`quantity` = B08 · `estimation` = B09. 남의 구획은 **읽기만** 한다.
|
|
`dataset_versions` 도 구획마다 따로 둔다 — 한 칸을 둘이 쓰면 저장할 때마다 서로 지운다.
|
|
|
|
⚠ **경계를 코드로 막는다** — `save_section()` 은 이름 붙은 한 구획만 갈아 끼우고 나머지는
|
|
원본 그대로 둔다. 통째로 덮는 길을 두지 않는 까닭은, 두 페이지가 같은 파일을 쓰기 때문이다
|
|
(오늘 `main.py` 에서 같은 모양의 사고를 이미 겪었다).
|
|
|
|
⚠ `dataset_versions` 는 **기록**이지 정본이 아니다
|
|
여기 적히는 것은 「저장 시점에 무엇을 고른 상태였나」이고, 계산을 되살릴 때 쓰는 정본은
|
|
**프로젝트 스냅샷**이다. 둘이 어긋나면 **스냅샷이 이긴다.**
|
|
|
|
⚠ `*_override` 는 기본이 `None` 이다
|
|
「프로젝트가 안 정했으면 `config` 정본을 쓴다」는 뜻이다. 기본값을 복사해 넣으면 나중에
|
|
정본이 바뀌어도 옛 프로젝트가 안 따라온다. 값을 넣는 것은 **설계자가 일부러 바꿨을 때만**이다.
|
|
|
|
⚠ 반영률 기본은 100 이다 (PLAN 8-11 · 8-10 ★법대로)
|
|
실무 관측 80/50/80 은 설계자가 비고란에 손으로 적은 값이지 법정값이 아니다. 기본값으로
|
|
넣지 않는다.
|
|
|
|
작업본 3층 (CLAUDE.md 5장)
|
|
조작은 캐시(sessionStorage)에 쌓이고 [저장]·[확정]에서 이 파일로 간다. 자동저장은 만들지 않는다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from common_util.common_util_json import atomic_write_json
|
|
|
|
SETTINGS_FILENAME = "project_settings.json"
|
|
SCHEMA_VERSION = 1
|
|
|
|
# 반영률 키 — 사면 계열과 짝이다. 값은 퍼센트이고 기본은 전부 100.
|
|
APPLICATION_RATIO_KEYS = (
|
|
"fill_slope_compaction", # 성토면다짐
|
|
"seed_spray_fill", # 초류종자살포(성토면)
|
|
"seed_spray_cut", # 초류종자살포(절토면)
|
|
"obstacle_removal", # 지장목제거
|
|
)
|
|
|
|
# 암 갈래 세트 — **개수를 코드에 박지 않는다**(PLAN 8-13).
|
|
# 울진 2 · 거창 5 · 오솔길 BOM 1 로 공사마다 다르다. 프로젝트가 하나를 고른다.
|
|
ROCK_CLASS_SETS: dict[str, tuple[str, ...]] = {
|
|
"single": ("토사", "암"),
|
|
"uljin2": ("토사", "연암", "발파암"),
|
|
"geochang5": ("토사", "풍화암", "연암", "보통암", "경암"),
|
|
}
|
|
DEFAULT_ROCK_CLASS_SET = "geochang5"
|
|
|
|
|
|
def default_settings() -> dict[str, Any]:
|
|
"""빈 설정. `estimation` 은 **자리만** 만든다 — 채우는 것은 B09 몫이다."""
|
|
return {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"quantity": {
|
|
"rock_class_set": DEFAULT_ROCK_CLASS_SET,
|
|
"rock_classes": list(ROCK_CLASS_SETS[DEFAULT_ROCK_CLASS_SET]),
|
|
# 갈래별 비율(%). 설계자가 넣는 값이라 기본은 비워 둔다 —
|
|
# 측점별 암질 판정에 기대지 않는다는 것이 8-1 사용자 확정이다.
|
|
"rock_ratios_pct": {},
|
|
"conversion_factors_override": None,
|
|
"haul_limits_m_override": None,
|
|
"application_ratios_pct": {key: 100 for key in APPLICATION_RATIO_KEYS},
|
|
"dataset_versions": {},
|
|
},
|
|
"estimation": {
|
|
# ⚠ 「연도」가 아니라 **판**을 가리킨다 — 조달청 제비율은 연중에도 개정된다
|
|
# (현행판 2026-04-13). 「2026년」만으로는 어느 판인지 안 정해진다.
|
|
# 값은 `dataset_id` + `effective_date` + `sha256` 세 쪽.
|
|
"rate_dataset": None,
|
|
"price_slot_names": {},
|
|
"dataset_versions": {},
|
|
},
|
|
}
|
|
|
|
|
|
def settings_path(project_root: str | Path) -> Path:
|
|
return Path(project_root) / SETTINGS_FILENAME
|
|
|
|
|
|
def load_settings(project_root: str | Path) -> dict[str, Any]:
|
|
"""설정을 읽는다. 파일이 없거나 깨졌으면 기본값을 돌려준다(예외를 올리지 않는다).
|
|
|
|
읽기가 실패해도 화면은 서야 한다 — 설정은 계산을 **거드는** 값이지 없으면 못 도는 값이 아니다.
|
|
"""
|
|
path = settings_path(project_root)
|
|
if not path.exists():
|
|
return default_settings()
|
|
try:
|
|
stored = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return default_settings()
|
|
if not isinstance(stored, dict):
|
|
return default_settings()
|
|
return _merge(default_settings(), stored)
|
|
|
|
|
|
def _merge(base: dict[str, Any], stored: dict[str, Any]) -> dict[str, Any]:
|
|
"""저장분을 기본값 위에 얹는다. **새로 생긴 키가 빠지지 않게** 한 겹만 재귀한다."""
|
|
merged = dict(base)
|
|
for key, value in stored.items():
|
|
current = merged.get(key)
|
|
if isinstance(current, dict) and isinstance(value, dict):
|
|
merged[key] = _merge(current, value)
|
|
else:
|
|
merged[key] = value
|
|
return merged
|
|
|
|
|
|
SECTIONS = ("quantity", "estimation")
|
|
|
|
|
|
def save_section(project_root: str | Path, section: str, values: dict[str, Any]) -> dict[str, Any]:
|
|
"""한 구획만 갈아 끼운다 — 남의 구획은 **손대지 않는다**.
|
|
|
|
두 페이지가 같은 파일을 쓰므로 통째로 덮으면 상대 값이 사라진다. 그래서 **통째로 쓰는
|
|
함수를 두지 않는다** — 쓰려면 반드시 구획 이름을 대야 한다.
|
|
"""
|
|
if section not in SECTIONS:
|
|
raise ValueError(f"모르는 구획: {section} (쓸 수 있는 것: {', '.join(SECTIONS)})")
|
|
settings = load_settings(project_root)
|
|
settings[section] = _merge(settings.get(section) or {}, values)
|
|
settings["schema_version"] = SCHEMA_VERSION
|
|
atomic_write_json(settings_path(project_root), settings)
|
|
return settings
|
|
|
|
|
|
def quantity_settings(project_root: str | Path) -> dict[str, Any]:
|
|
"""B08 구획만 꺼낸다."""
|
|
return load_settings(project_root).get("quantity") or {}
|
|
|
|
|
|
def rock_classes(settings: dict[str, Any]) -> list[str]:
|
|
"""이 프로젝트의 암 갈래 목록. 세트 이름이 낯설면 저장된 목록을 그대로 쓴다."""
|
|
stored = settings.get("rock_classes")
|
|
if isinstance(stored, list) and stored:
|
|
return [str(item) for item in stored]
|
|
name = str(settings.get("rock_class_set") or DEFAULT_ROCK_CLASS_SET)
|
|
return list(ROCK_CLASS_SETS.get(name, ROCK_CLASS_SETS[DEFAULT_ROCK_CLASS_SET]))
|
|
|
|
|
|
def application_ratio(settings: dict[str, Any], key: str) -> float:
|
|
"""반영률을 0~1 로. 없으면 100 %(=1.0) — 실무 관측치를 기본값으로 쓰지 않는다."""
|
|
raw = (settings.get("application_ratios_pct") or {}).get(key, 100)
|
|
try:
|
|
return float(raw) / 100.0
|
|
except (TypeError, ValueError):
|
|
return 1.0
|