Files
Aislo/common_util/common_util_project_settings.py
T
eomsangdonandClaude Opus 5 5780eb9359 feat(B09): 흙깎기가 처음으로 금액이 섬 — 범위 계수·장비 규격을 고르는 칸 (확정 ①)
사용자 확정 ① — 작업효율 0.50(두 끝의 평균). 딸림 지시 「값을 코드에 박고 끝내지 말 것 ·
화면에 칸으로 세우고 근거를 보이고 바꿀 수 있게」를 그대로 구현.

막혔던 자리 둘
- 품셈 9-3-2 가 작업효율을 「0.55∼0.45」 범위로 줌 → 확정값이 아니라 계수가 안 섬
- 그 표에 장비가 없음 → [주]① 「무한궤도 굴착기(0.7㎥)」가 마스터에 안 실려 기종을 못 고름
⇒ 두 자리를 채워 흙깎기 단가가 처음으로 섬: 2,515.1원/㎥ × 2,355.84㎥ ≒ 592만원

구현
- `B09_Estimation_FactorChoices` 신설 — 범위 칸을 품셈에서 훑어 모으고(코드 안 박음),
  고를 수 있는 것은 원문 두 끝과 그 평균 셋뿐. 기본은 평균
- 장비 규격도 같은 결로: 흙깎기는 [주] 에만 있는 값을 채우는 칸, 층따기는 원문 0.7㎥ 를
  기본으로 두고 실무(영월 0.2㎥)로 바꿀 수 있는 칸
- 고른 값은 프로젝트 설정 `estimation` 구획에 저장 — 프로젝트마다 갈림
- `cached_build` 를 고른 값별로 캐시 (전역 한 벌이면 한 프로젝트가 남의 금액을 흔듦)
- `GET/PUT /{project_id}/estimation/factors` · 기초자료 탭 맨 위에 칸과 근거 표시

⚠ 짓다 잡은 것 — 「0.45-0.05」를 범위로 잘못 읽고 있었음. 그건 뺄셈(=0.40)이라
품셈이 이미 정한 값인데 「고를 것」으로 둔갑했음. 물결(∼) 일 때만 범위로 봄

검증: pytest 279 통과(신규 7) · tsc 통과 · 평균 2,515.1 / 하한 2,794.6 으로
고른 값이 단가에 실제로 닿는 것 확인
⚠ 화면 확인은 못 함 — 내 창 브라우저 세션이 만료됐고 자격 파일이 이 폴더에 없음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 06:27:24 +09:00

239 lines
12 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, Iterable
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"
# 암 시공법 — 품셈이 공종을 가르는 기준. `None` 은 「아직 안 정함」이고 기본값이다.
ROCK_METHOD_RIPPING = "ripping" # 긁어내기 — 암절취
ROCK_METHOD_BLASTING = "blasting" # 터뜨리기 — 발파암
ROCK_METHODS = (ROCK_METHOD_RIPPING, ROCK_METHOD_BLASTING)
def rock_method(settings: dict[str, Any], rock_class: str) -> str | None:
"""갈래 하나의 시공법. 안 정했으면 `None` — **기본값으로 때우지 않는다.**"""
value = (settings.get("rock_methods") or {}).get(rock_class)
return value if value in ROCK_METHODS else None
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": {},
# 갈래별 **시공법** — `{갈래이름: "ripping"|"blasting"}`.
# ⚠ 갈래 이름(연암·보통암…)만으로는 **긁어내는 암인지 터뜨리는 암인지** 알 수 없고,
# 품셈은 그 둘을 다른 공종으로 둔다(암절취 FP-09-04 / 발파암 FP-09-05).
# 기본은 **비워 둔다** — 찍으면 공종이 조용히 틀린다. 안 정하면 인계에서
# 「시공법 미지정」으로 드러난다(2026-09-07 일감 9 에서 드러난 자리).
"rock_methods": {},
"conversion_factors_override": None,
"haul_limits_m_override": None,
"application_ratios_pct": {key: 100 for key in APPLICATION_RATIO_KEYS},
# 자재총괄의 관급/사급 구분 — `{자재명: "owner_supplied"|"contractor_supplied"}`
# 또는 `{자재명: {"supply": …, "install_by": "contractor"|"owner"}}`.
# ⚠ **법이 아니라 발주 결정**이라 기본은 비워 둔다. 안 정한 자재는 「미분류」로
# 화면에 드러난다 — 사급으로 조용히 넘기면 관급자재대가 새 나간다.
# 표토제거 두께(m) — ⚠ **품셈이 정하는 값이 아니다.** 9-15 [주]② 가
# 「T : 표토두께(m)」로 **공식의 입력 변수**로 두었다(2026-09-07 원문 확인).
# 기본값을 두지 않는다 — 안 넣으면 물량을 안 낸다(0 으로 때우지 않음).
"topsoil_thickness_m": None,
"material_supply": {},
# 콘크리트 타설 방식 — `ready_mixed`(FP-12-01-01) / `machine_mixed`(-02) /
# `hand_mixed`(-03). **설계 판단**이라 사용자가 고른다.
# ⚠ 기본은 `None` — 「안 정함」과 「일부러 레디믹스트를 고른 것」을 갈라야
# 화면이 「기본값 적용 중」을 정직하게 띄운다. 값을 미리 넣으면 그 구별이 사라진다.
"concrete_placing_method": None,
# 부대시설 개소 — `{항목키: 개소}` (2026-09-09 사용자 확정 ⑬).
# ⚠ **산식으로 만들지 않는다.** 국가지점번호판은 임도규정 제26조제5항이
# 「500미터 마다 설치·관리하되 **필요시 거리를 조정**」이라 하고, 기점 포함·
# 종점 잔여·갈림길 중복을 원문이 안 정한다(지식DB 부대시설 [구현]).
# ⇒ `ceil(연장÷500)` 을 확정 산식으로 쓰지 않고 **개소를 받는다.**
# 비워 두면 물량을 안 낸다(0 으로 때우지 않음).
"ancillary_counts": {},
# 층따기 길이(깊이, m) — ⭐ 2026-09-09 사용자 확정 2차 ①.
# ⚠ **면적이 정본**이고 부피는 **면적 × 이 길이**로 낸다. 그러면 품셈 ㎥ 단가를
# 그대로 쓸 수 있다(단위 불일치가 풀림).
# ⚠ 기본값을 두지 않는다 — 안 넣으면 물량을 안 낸다(0 으로 때우지 않음).
# 교본이 「층따기 높이·폭은 **설계도서에 명시**」라 해 설계 입력이다.
"bench_cut_depth_m": None,
"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],
*,
replace_keys: Iterable[str] = (),
) -> dict[str, Any]:
"""한 구획만 갈아 끼운다 — 남의 구획은 **손대지 않는다**.
두 페이지가 같은 파일을 쓰므로 통째로 덮으면 상대 값이 사라진다. 그래서 **통째로 쓰는
함수를 두지 않는다** — 쓰려면 반드시 구획 이름을 대야 한다.
⚠ `replace_keys` — **지울 수 있어야 하는 칸**은 병합이 아니라 통째로 갈아 끼운다.
「고른 값을 안 정함으로 되돌리기」가 병합으로는 안 되기 때문이다(2026-09-07 화면에서
걸린 자리 — 시공법을 한 번 고르면 되돌릴 길이 없었다).
"""
if section not in SECTIONS:
raise ValueError(f"모르는 구획: {section} (쓸 수 있는 것: {', '.join(SECTIONS)})")
settings = load_settings(project_root)
merged = _merge(settings.get(section) or {}, values)
for key in replace_keys:
if key in values:
merged[key] = values[key]
settings[section] = merged
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 estimation_settings(project_root: str | Path) -> dict[str, Any]:
"""B09 구획만 꺼낸다 — 범위 계수·장비 규격처럼 **사용자가 고른 단가 조건**이 여기 산다."""
return load_settings(project_root).get("estimation") 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]))
#: 타설 방식 — 안 정했을 때 쓰는 값. **금액에 바로 걸리는 자리**라 화면이 잠정임을 띄운다.
CONCRETE_PLACING_METHODS = ("ready_mixed", "machine_mixed", "hand_mixed")
DEFAULT_CONCRETE_PLACING_METHOD = "ready_mixed"
def concrete_placing_method(settings: dict[str, Any]) -> tuple[str, bool]:
"""(타설 방식, 기본값을 쓰고 있는가).
⚠ 둘째 값이 참이면 **사용자가 아직 안 정한 것**이다 — 화면이 「기본값 적용 중」을 띄워야
한다. 조용히 기본으로 돌면 사용자는 그것이 잠정인 줄도 모른다.
"""
stored = settings.get("concrete_placing_method")
if stored in CONCRETE_PLACING_METHODS:
return str(stored), False
return DEFAULT_CONCRETE_PLACING_METHOD, True
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