feat(B08): 토공집계표·운반 가중평균 엔진 + 프로젝트 설정 자리
일감 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>
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
"""토공집계표 — 토적표·사면표를 공종별 총량으로 모은다 (B08 일감 4 · PLAN 8-11).
|
||||
|
||||
열 구성 (거창 실무 `토공집계표` 시트 그대로)
|
||||
`구분 · 공종 · 규격 · 단위 · 계 · 비고`
|
||||
|
||||
암 갈래는 **개수를 코드에 박지 않는다** (PLAN 8-13)
|
||||
울진 2갈래(연암·발파암) · 거창 5갈래(토사·풍화암·연암·보통암·경암) · 오솔길 BOM 1갈래로
|
||||
공사마다 다르다. 프로젝트 설정의 세트를 받아 그만큼 줄을 낸다.
|
||||
|
||||
⚠ 측점별 암질 판정에 기대지 않는다(PLAN 8-1 사용자 확정) — 절토량은 기하에서 나오고
|
||||
**암/토사 나눔과 갈래 비율은 설계자 입력**이다. 그래서 여기서는 토적표의 「암」 총량을
|
||||
설계자가 준 비율(%)로 나눠 줄을 만든다.
|
||||
|
||||
⚠ 반영률은 법정값이 아니다 (PLAN 8-11)
|
||||
실무 시트가 「성토면 80 % 반영」처럼 비고란에 손으로 적어 둔 값이다. **기본 100 %** 이고
|
||||
설계자가 바꾼다. 실무 관측치(80/50/80)는 참고이지 기본값이 아니다.
|
||||
|
||||
⚠ 무대(소운반 20m)는 집계에는 오르되 **내역 줄이 되지 않는다** (PLAN 8-7 ㉡)
|
||||
품셈 1-2-7 「소운반 20 m 이내는 품에 포함」. 켜도 붙일 단가가 품셈에 없다.
|
||||
그래서 `in_bill=False` 로 표시해 넘긴다 — 값은 검산(`무대+도자+덤프 = 총 운반토량`)에 쓴다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable
|
||||
|
||||
# 지반 구분이 붙는 공종 — 실무 시트가 이 셋을 각각 암 갈래만큼 늘려 적는다.
|
||||
GROUND_SPLIT_GROUPS = ("흙깎기", "측구터파기", "구조물터파기")
|
||||
|
||||
# 반영률 키 ↔ 집계 공종. 값은 프로젝트 설정에서 온다(기본 100 %).
|
||||
RATIO_OF_ROW = {
|
||||
"성토면다짐": "fill_slope_compaction",
|
||||
"초류종자살포": "seed_spray_fill", # 성토면 몫에만 걸린다 — 절토면은 별도 키
|
||||
"지장목제거": "obstacle_removal",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SummaryRow:
|
||||
"""집계표 한 줄. 이름·단위는 거창 실무 시트 문구를 따른다."""
|
||||
|
||||
group: str # 구분 (흙깎기·성토·…)
|
||||
item: str = "" # 공종 (토사·연암·…)
|
||||
spec: str = "" # 규격 (기계(굴삭기)·백호우·…)
|
||||
unit: str = "㎥"
|
||||
amount: float = 0.0
|
||||
note: str = ""
|
||||
# 내역서 줄이 되는가 — 무대처럼 품에 포함된 것은 False (PLAN 8-7 ㉡).
|
||||
in_bill: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SummaryInput:
|
||||
"""집계에 필요한 값 묶음. 토적표·사면표·운반계획에서 이미 나온 것만 받는다."""
|
||||
|
||||
earthwork_totals: dict[str, float] = field(default_factory=dict)
|
||||
slope_totals: dict[str, float] = field(default_factory=dict)
|
||||
haul_rows: list[dict[str, Any]] = field(default_factory=list)
|
||||
rock_classes: list[str] = field(default_factory=list)
|
||||
rock_ratios_pct: dict[str, float] = field(default_factory=dict)
|
||||
application_ratios: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _ratio(source: SummaryInput, key: str) -> float:
|
||||
"""반영률(0~1). 없으면 1.0 — 실무 관측치를 기본값으로 쓰지 않는다."""
|
||||
value = source.application_ratios.get(key)
|
||||
return float(value) if isinstance(value, (int, float)) else 1.0
|
||||
|
||||
|
||||
def _split_by_rock(total: float, source: SummaryInput) -> list[tuple[str, float]]:
|
||||
"""암 총량을 설계자가 준 비율(%)로 갈래별로 나눈다.
|
||||
|
||||
비율이 아직 없으면 **나누지 않고 「암」 한 줄로** 낸다 — 지어낸 비율로 쪼개지 않는다.
|
||||
"""
|
||||
classes = [name for name in source.rock_classes if name != "토사"]
|
||||
ratios = {name: float(source.rock_ratios_pct.get(name, 0) or 0) for name in classes}
|
||||
given = sum(ratios.values())
|
||||
if given <= 0:
|
||||
return [("암", total)]
|
||||
return [(name, total * ratios[name] / given) for name in classes if ratios[name] > 0]
|
||||
|
||||
|
||||
def build_rows(source: SummaryInput) -> list[SummaryRow]:
|
||||
"""토공집계표 줄 목록. 값이 0 인 갈래도 줄은 남긴다(실무 시트가 그렇다)."""
|
||||
earth = source.earthwork_totals
|
||||
slope = source.slope_totals
|
||||
rows: list[SummaryRow] = []
|
||||
|
||||
# ── 흙깎기 · 측구터파기 — 토사 한 줄 + 암 갈래만큼 ──────────────
|
||||
for group, soil_key, rock_key in (
|
||||
("흙깎기", "cut_soil_volume_m3", "cut_rock_volume_m3"),
|
||||
("측구터파기", "ditch_soil_volume_m3", "ditch_rock_volume_m3"),
|
||||
):
|
||||
rows.append(
|
||||
SummaryRow(
|
||||
group=group, item="토사", spec="기계(굴삭기)", amount=earth.get(soil_key, 0.0)
|
||||
)
|
||||
)
|
||||
for name, amount in _split_by_rock(earth.get(rock_key, 0.0), source):
|
||||
rows.append(SummaryRow(group=group, item=name, spec="굴삭기+브레카", amount=amount))
|
||||
|
||||
rows.append(SummaryRow(group="보정량계", amount=earth.get("adjusted_total_m3", 0.0)))
|
||||
rows.append(SummaryRow(group="성토", amount=earth.get("fill_volume_m3", 0.0)))
|
||||
|
||||
# ── 운반 — 수단별. 무대는 집계에 오르되 내역 줄이 아니다 ────────
|
||||
rows.extend(_haul_rows(source))
|
||||
|
||||
# ── 사면 계열 — 반영률이 여기서 걸린다 ────────────────────────
|
||||
fill_face = slope.get("face_dressing_fill", 0.0)
|
||||
cut_face = slope.get("face_dressing_cut", 0.0)
|
||||
rows.append(
|
||||
SummaryRow(
|
||||
group="성토면다짐",
|
||||
unit="㎡",
|
||||
amount=fill_face * _ratio(source, "fill_slope_compaction"),
|
||||
note=_ratio_note(source, "fill_slope_compaction", "성토면"),
|
||||
)
|
||||
)
|
||||
seed = fill_face * _ratio(source, "seed_spray_fill") + cut_face * _ratio(
|
||||
source, "seed_spray_cut"
|
||||
)
|
||||
rows.append(
|
||||
SummaryRow(
|
||||
group="초류종자살포",
|
||||
spec="씨드스프레이",
|
||||
unit="㎡",
|
||||
amount=seed,
|
||||
note=_seed_note(source),
|
||||
)
|
||||
)
|
||||
removal = slope.get("tree_removal_fill", 0.0) + slope.get("tree_removal_cut", 0.0)
|
||||
rows.append(
|
||||
SummaryRow(
|
||||
group="지장목제거",
|
||||
unit="㎡",
|
||||
amount=removal * _ratio(source, "obstacle_removal"),
|
||||
note=_ratio_note(source, "obstacle_removal", "성토면+절토면"),
|
||||
)
|
||||
)
|
||||
rows.append(
|
||||
SummaryRow(
|
||||
group="층따기", spec="백호우", unit="㎡", amount=slope.get("bench_cut_fill", 0.0)
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _ratio_note(source: SummaryInput, key: str, base: str) -> str:
|
||||
ratio = _ratio(source, key)
|
||||
return "" if abs(ratio - 1.0) < 1e-9 else f"{base} {ratio * 100:g} % 반영"
|
||||
|
||||
|
||||
def _seed_note(source: SummaryInput) -> str:
|
||||
fill = _ratio(source, "seed_spray_fill")
|
||||
cut = _ratio(source, "seed_spray_cut")
|
||||
if abs(fill - 1.0) < 1e-9 and abs(cut - 1.0) < 1e-9:
|
||||
return ""
|
||||
return f"성토면 {fill * 100:g} % 반영 + 절토면 {cut * 100:g} % 반영"
|
||||
|
||||
|
||||
# 운반수단 표기 — `HaulPlan` 의 키를 실무 시트 문구로 옮긴다.
|
||||
HAUL_LABELS = {"free_haul": "무대(종방향유용토)", "dozer": "도자운반", "dump_truck": "덤프운반"}
|
||||
|
||||
|
||||
def _haul_rows(source: SummaryInput) -> list[SummaryRow]:
|
||||
"""운반 — (운반수단 × 지반유형)별 가중평균 줄 (PLAN 8-3).
|
||||
|
||||
무대는 `in_bill=False` — 품셈 1-2-7 로 품에 포함돼 단가가 없다. 값은 검산에 쓴다.
|
||||
"""
|
||||
rows: list[SummaryRow] = []
|
||||
for item in source.haul_rows:
|
||||
key = str(item.get("equipment") or "")
|
||||
label = HAUL_LABELS.get(key, key or "운반")
|
||||
ground = str(item.get("ground") or "")
|
||||
distance = item.get("average_distance_m")
|
||||
note = f"평균운반거리 {float(distance):.2f} m" if isinstance(distance, (int, float)) else ""
|
||||
if key == "free_haul":
|
||||
note = (note + " · 내역 제외(품에 포함)").strip(" ·")
|
||||
rows.append(
|
||||
SummaryRow(
|
||||
group=label,
|
||||
item=ground,
|
||||
amount=float(item.get("volume_m3") or 0.0),
|
||||
note=note,
|
||||
in_bill=key != "free_haul",
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def build_table(source: SummaryInput) -> dict[str, Any]:
|
||||
"""화면·API 가 그대로 쓰는 모양."""
|
||||
rows = build_rows(source)
|
||||
return {
|
||||
"columns": ["구분", "공종", "규격", "단위", "계", "비고"],
|
||||
"rock_classes": list(source.rock_classes),
|
||||
"rock_ratios_pct": dict(source.rock_ratios_pct),
|
||||
"application_ratios": dict(source.application_ratios),
|
||||
"rows": [
|
||||
{
|
||||
"group": row.group,
|
||||
"item": row.item,
|
||||
"spec": row.spec,
|
||||
"unit": row.unit,
|
||||
"amount": row.amount,
|
||||
"note": row.note,
|
||||
"in_bill": row.in_bill,
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
"row_count": len(rows),
|
||||
}
|
||||
|
||||
|
||||
def haul_check(source: SummaryInput, earthwork_totals: dict[str, float]) -> dict[str, Any]:
|
||||
"""검산 — `무대 + 도자 + 덤프` 합이 총 운반토량과 맞는가 (PLAN 8-7 ㉡).
|
||||
|
||||
무대를 안 내면 이 검산이 안 된다. 그래서 값은 내되 내역 줄만 빼는 것이다.
|
||||
"""
|
||||
hauled = sum(float(item.get("volume_m3") or 0.0) for item in source.haul_rows)
|
||||
diverted = float(earthwork_totals.get("diverted_m3") or 0.0)
|
||||
return {
|
||||
"hauled_total_m3": hauled,
|
||||
"diverted_total_m3": diverted,
|
||||
"difference_m3": hauled - diverted,
|
||||
}
|
||||
|
||||
|
||||
def totals_by_unit(rows: Iterable[SummaryRow]) -> dict[str, float]:
|
||||
"""단위별 합계 — ㎥ 와 ㎡ 를 섞어 더하지 않는다."""
|
||||
result: dict[str, float] = {}
|
||||
for row in rows:
|
||||
result[row.unit] = result.get(row.unit, 0.0) + row.amount
|
||||
return result
|
||||
@@ -0,0 +1,217 @@
|
||||
"""운반 가중평균 — 내역 줄이 되는 4줄과 그 근거 (B08 일감 5 · PLAN 8-3·8-7).
|
||||
|
||||
무엇을 내나
|
||||
실무는 **(운반수단 × 지반유형)별 가중평균 1개**를 내역에 올린다. 울진 실측 —
|
||||
「도자 토사 1,170㎥ 평균 43.66m · 도자 암 1,554㎥ 39.07m · 덤프 토사 1,667㎥ 293.78m ·
|
||||
덤프 암 1,714㎥ 318.6m」로 **4줄**이다. 오솔길도 분류별 가중평균 1개를 낸다
|
||||
(거창 무대: 10,399 ÷ 871 = 11.94m).
|
||||
|
||||
개별 구간 줄은 버리지 않고 **근거**로 함께 낸다 — 어느 구간이 그 평균을 만들었는지
|
||||
되짚을 수 있어야 한다.
|
||||
|
||||
가중평균 = Σ(토량 × 거리) ÷ Σ(토량)
|
||||
실무 산출서가 「토량 × 거리」를 쌓아 나누는 그 식이다. 단순평균이 아니다.
|
||||
|
||||
⚠ 무대(`free_haul`)는 내역 줄이 되지 않는다 (PLAN 8-7 ㉡)
|
||||
품셈 1-2-7 「소운반 20 m 이내는 품에 포함」. 켜도 붙일 단가가 품셈에 없다 —
|
||||
인력운반은 `10-6` 「소운반 20 m **초과분**」이다. 그래서 `in_bill=False` 로 표시해 넘기고
|
||||
값은 검산(`무대+도자+덤프 = 총 운반토량`)에 쓴다.
|
||||
|
||||
입력은 `HaulPlan` 이다 (이미 있는 값 — 다시 세지 않는다)
|
||||
띠(`bands`)마다 `equipment` · `haul_distance_m` · 지반유형별 물량(`ea_m3`·`rr_m3`·`br_m3`)이
|
||||
들어 있다. 떨어진 구간끼리 옮기는 `transfers` 도 같은 모양이라 함께 센다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable
|
||||
|
||||
# 지반유형 키 ↔ 표기. `HaulPlan` 이 절토 구간 구성비로 안분해 둔 세 갈래다.
|
||||
GROUND_LABELS = {"ea_m3": "토사", "rr_m3": "리핑암", "br_m3": "발파암"}
|
||||
# 무대 — 품에 포함이라 내역 줄이 되지 않는다.
|
||||
FREE_HAUL_KEY = "free_haul"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HaulLeg:
|
||||
"""근거 줄 하나 — 어느 구간을 얼마나 몇 m 옮겼나."""
|
||||
|
||||
equipment: str
|
||||
ground: str
|
||||
volume_m3: float
|
||||
distance_m: float
|
||||
from_m: float
|
||||
to_m: float
|
||||
source: str # `band` 또는 `transfer`
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HaulSummaryRow:
|
||||
"""내역 줄 — (운반수단 × 지반유형) 하나."""
|
||||
|
||||
equipment: str
|
||||
ground: str
|
||||
volume_m3: float = 0.0
|
||||
work_m3m: float = 0.0 # Σ(토량 × 거리) — 가중평균의 분자
|
||||
legs: int = 0
|
||||
in_bill: bool = True
|
||||
|
||||
@property
|
||||
def average_distance_m(self) -> float:
|
||||
return self.work_m3m / self.volume_m3 if self.volume_m3 else 0.0
|
||||
|
||||
|
||||
def _legs_of(plan: dict[str, Any]) -> list[HaulLeg]:
|
||||
"""`HaulPlan` → 근거 줄 목록. 띠와 장거리 이동을 같은 모양으로 편다."""
|
||||
legs: list[HaulLeg] = []
|
||||
|
||||
def push(
|
||||
item: dict[str, Any],
|
||||
equipment: str | None,
|
||||
distance: Any,
|
||||
source: str,
|
||||
from_m: Any,
|
||||
to_m: Any,
|
||||
) -> None:
|
||||
if not equipment or not isinstance(distance, (int, float)):
|
||||
return
|
||||
for key, label in GROUND_LABELS.items():
|
||||
volume = item.get(key)
|
||||
if not isinstance(volume, (int, float)) or volume <= 0:
|
||||
continue
|
||||
legs.append(
|
||||
HaulLeg(
|
||||
equipment=str(equipment),
|
||||
ground=label,
|
||||
volume_m3=float(volume),
|
||||
distance_m=float(distance),
|
||||
from_m=float(from_m or 0.0),
|
||||
to_m=float(to_m or 0.0),
|
||||
source=source,
|
||||
)
|
||||
)
|
||||
|
||||
for block in plan.get("blocks") or []:
|
||||
for band in block.get("bands") or []:
|
||||
push(
|
||||
band,
|
||||
band.get("equipment"),
|
||||
band.get("haul_distance_m"),
|
||||
"band",
|
||||
band.get("haul_from_m"),
|
||||
band.get("haul_to_m"),
|
||||
)
|
||||
for transfer in plan.get("transfers") or []:
|
||||
push(
|
||||
transfer,
|
||||
transfer.get("equipment"),
|
||||
transfer.get("haul_distance_m"),
|
||||
"transfer",
|
||||
transfer.get("from_m"),
|
||||
transfer.get("to_m"),
|
||||
)
|
||||
return legs
|
||||
|
||||
|
||||
def summarize(legs: Iterable[HaulLeg]) -> list[HaulSummaryRow]:
|
||||
"""(운반수단 × 지반유형)별 가중평균. 실무 내역이 이 줄들을 그대로 쓴다."""
|
||||
grouped: dict[tuple[str, str], HaulSummaryRow] = {}
|
||||
for leg in legs:
|
||||
key = (leg.equipment, leg.ground)
|
||||
row = grouped.get(key)
|
||||
if row is None:
|
||||
row = HaulSummaryRow(
|
||||
equipment=leg.equipment,
|
||||
ground=leg.ground,
|
||||
in_bill=leg.equipment != FREE_HAUL_KEY,
|
||||
)
|
||||
grouped[key] = row
|
||||
row.volume_m3 += leg.volume_m3
|
||||
row.work_m3m += leg.volume_m3 * leg.distance_m
|
||||
row.legs += 1
|
||||
# 수단 → 지반유형 순으로 안정 정렬 — 화면·내역 줄 순서가 매번 같아야 한다.
|
||||
order = {FREE_HAUL_KEY: 0, "dozer": 1, "dump_truck": 2}
|
||||
labels = list(GROUND_LABELS.values())
|
||||
return sorted(
|
||||
grouped.values(),
|
||||
key=lambda row: (
|
||||
order.get(row.equipment, 9),
|
||||
labels.index(row.ground) if row.ground in labels else 9,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_table(plan: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""화면·API 가 그대로 쓰는 모양. 내역 줄과 근거 줄을 함께 낸다."""
|
||||
legs = _legs_of(plan or {})
|
||||
rows = summarize(legs)
|
||||
return {
|
||||
"method": "volume_weighted_average",
|
||||
"rows": [
|
||||
{
|
||||
"equipment": row.equipment,
|
||||
"ground": row.ground,
|
||||
"volume_m3": row.volume_m3,
|
||||
"average_distance_m": row.average_distance_m,
|
||||
"work_m3m": row.work_m3m,
|
||||
"legs": row.legs,
|
||||
"in_bill": row.in_bill,
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
# 근거 — 어느 구간이 그 평균을 만들었나. 내역에는 안 오른다.
|
||||
"legs": [
|
||||
{
|
||||
"equipment": leg.equipment,
|
||||
"ground": leg.ground,
|
||||
"volume_m3": leg.volume_m3,
|
||||
"distance_m": leg.distance_m,
|
||||
"from_m": leg.from_m,
|
||||
"to_m": leg.to_m,
|
||||
"source": leg.source,
|
||||
}
|
||||
for leg in legs
|
||||
],
|
||||
"bill_row_count": sum(1 for row in rows if row.in_bill),
|
||||
}
|
||||
|
||||
|
||||
def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""토공집계표가 받는 모양으로 줄인다 — 집계표는 근거 줄을 안 쓴다."""
|
||||
return [
|
||||
{
|
||||
"equipment": row["equipment"],
|
||||
"ground": row["ground"],
|
||||
"volume_m3": row["volume_m3"],
|
||||
"average_distance_m": row["average_distance_m"],
|
||||
}
|
||||
for row in table.get("rows") or []
|
||||
]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HaulCheck:
|
||||
"""검산 — 무대를 안 내면 이 대조가 죽는다(PLAN 8-7 ㉡)."""
|
||||
|
||||
hauled_total_m3: float = 0.0
|
||||
plan_total_m3: float = 0.0
|
||||
difference_m3: float = 0.0
|
||||
details: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
|
||||
def check_against_plan(table: dict[str, Any], plan: dict[str, Any] | None) -> HaulCheck:
|
||||
"""`무대 + 도자 + 덤프` 합이 `HaulPlan` 의 총 운반량과 맞는가."""
|
||||
hauled = sum(float(row.get("volume_m3") or 0.0) for row in table.get("rows") or [])
|
||||
plan = plan or {}
|
||||
planned = float(plan.get("hauled_m3") or 0.0) + float(plan.get("transferred_m3") or 0.0)
|
||||
by_equipment: dict[str, float] = {}
|
||||
for row in table.get("rows") or []:
|
||||
key = str(row.get("equipment"))
|
||||
by_equipment[key] = by_equipment.get(key, 0.0) + float(row.get("volume_m3") or 0.0)
|
||||
return HaulCheck(
|
||||
hauled_total_m3=hauled,
|
||||
plan_total_m3=planned,
|
||||
difference_m3=hauled - planned,
|
||||
details=by_equipment,
|
||||
)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""프로젝트 설정 — 수량(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
|
||||
Reference in New Issue
Block a user