Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1

This commit is contained in:
2026-09-07 23:13:51 +09:00
11 changed files with 1732 additions and 22 deletions
@@ -0,0 +1,246 @@
"""토공집계표 — 토적표·사면표를 공종별 총량으로 모은다 (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, str]]:
"""암 총량을 설계자가 준 비율(%)로 갈래별로 나눈다. `(이름, 물량, 비고)`.
비율이 아직 없으면 **나누지 않고 「암」 한 줄로** 낸다 — 지어낸 비율로 쪼개지 않는다.
⚠ 합이 100 이 아니어도 **총량은 보존**한다 — 준 비율끼리 안분한다. 물량이 조용히
사라지면 안 되기 때문이다. 다만 값이 말없이 바뀌는 것이므로 **비고에 드러낸다**
(60/30 을 넣으면 실제로는 66.7/33.3 으로 돈다).
"""
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, "")]
note = "" if abs(given - 100.0) < 1e-9 else f"입력 합 {given:g} % → 100 % 로 안분"
return [
(name, total * ratios[name] / given, note) 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, note in _split_by_rock(earth.get(rock_key, 0.0), source):
rows.append(
SummaryRow(
group=group, item=name, spec="굴삭기+브레카", amount=amount, note=note
)
)
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,165 @@
"""사면 4계열 면적 — 실무 토적표의 오른쪽 절반 (B08 일감 3 · PLAN 8-4b).
무엇을 내나
실무 토적표 V~AI 열에 해당한다. 계열 넷 × 성토면/절토면 2벌 = **(거리, 면적) 7쌍**
(층따기는 성토면만이라 7쌍이다).
층따기[성토면] · 면고르기[성토면·절토면] · 법면보호공[성토면·절토면] · 지장목제거[성토면·절토면]
여기서 「거리」는 그 측점의 **사면길이**이고, 면적은 토적표와 **같은 평균단면적법**으로
낸다 — 계산을 두 벌로 짜지 않는다.
법면보호공은 면고르기를 참조한다 (PLAN 8-4b)
실무 시트에서 둘의 값이 완전히 같았는데, 그것은 **엑셀에서 면고르기 열을 복사한 것**이고
오솔길 산출(`1.BOM`)에는 보호공 4열이 **0** 으로 비어 있었다. 즉 산출값이 아니라 참조다.
그래서 기본은 참조로 두되 **끊을 수 있게** 한다 — 실제 보호 대상이 면고르기 대상과
다를 수 있기 때문이다.
⚠ 반영률은 법정값이 아니다 (PLAN 8-11 · 8-10 ★)
실무 시트가 「성토면 80 % 반영」처럼 비고란에 손으로 적어 둔 값이다. **프로그램 기본은
100 %** 이고 설계자가 바꾼다. 실무 관측치(80/50/80)는 기본값 후보가 아니라 참고다.
⚠ 소단 평탄부는 사면적에 넣지 않는다
면고르기·종자파종의 대상은 「사면」이고 소단은 평평한 턱이다. `SlopeSegment` 자체가
평탄부를 빼고 나오므로 여기서 다시 거를 것이 없다. 다만 **소단이 늘수록 사면적이 줄어드는
것이 눈에 보여야** 하므로 측점마다 소단 폭을 함께 싣는다.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Iterable
from B08_Quantity.B08_Quantity_Engine_SlopeLength import StationSlope
# 계열 이름 — 실무 토적표 머리글 그대로. `fill`/`cut` 은 성토면/절토면이다.
SERIES: tuple[tuple[str, tuple[str, ...]], ...] = (
("bench_cut", ("fill",)), # 층따기 — 성토면만(원지반이 급한 곳을 계단으로 깎는다)
("face_dressing", ("fill", "cut")), # 면고르기
("slope_protection", ("fill", "cut")), # 법면보호공(종자파종)
("tree_removal", ("fill", "cut")), # 지장목제거
)
# 법면보호공이 참조하는 계열 — 기본은 면고르기다(위 설명 참조).
PROTECTION_SOURCE = "face_dressing"
@dataclass(slots=True)
class SlopeRatios:
"""계열별 반영률(0~1). 기본 100 % — 실무 관측치는 참고일 뿐 기본값이 아니다.
⚠ TODO(미결 · PLAN 8-11) — 실무 관측 80/50/80 중 **지장목제거는 밑수가 안 맞는다**
(성토+절토 합의 80 % = 14,061 ≠ 시트값 10,782). 밑수를 못 찾았으므로 쫓지 않고
100 % 로 둔다. 근거가 나오면 이 값만 바꾼다.
"""
bench_cut: float = 1.0
face_dressing: float = 1.0
slope_protection: float = 1.0
tree_removal: float = 1.0
def of(self, series: str) -> float:
return float(getattr(self, series, 1.0))
@dataclass(slots=True)
class SlopeAreaRow:
"""측점 하나의 사면 계열 값. `lengths` 는 거리(사면길이), `areas` 는 면적."""
chainage_m: float
distance_m: float = 0.0
berm_width_m: float = 0.0
unclosed: bool = False
lengths: dict[str, float] = field(default_factory=dict)
areas: dict[str, float] = field(default_factory=dict)
def _key(series: str, face: str) -> str:
return f"{series}_{face}"
def _length_of(slope: StationSlope, series: str, face: str) -> float:
"""계열·면별 「거리」 = 그 측점의 사면길이.
법면보호공은 면고르기를 참조한다 — 같은 사면길이를 쓴다. 끊고 싶으면 이 함수만 고친다.
층따기는 성토면만 대상이다.
"""
if series == "bench_cut" and face != "fill":
return 0.0
return slope.fill_length_m if face == "fill" else slope.cut_length_m
def build_rows(
slopes: Iterable[StationSlope], ratios: SlopeRatios | None = None
) -> list[SlopeAreaRow]:
"""측점별 사면길이 → 계열별 (거리, 면적). 면적은 토적표와 같은 평균단면적법."""
rates = ratios or SlopeRatios()
ordered = sorted(slopes, key=lambda s: s.chainage_m)
rows: list[SlopeAreaRow] = []
previous: SlopeAreaRow | None = None
for slope in ordered:
row = SlopeAreaRow(
chainage_m=slope.chainage_m,
berm_width_m=slope.berm_width_m,
unclosed=slope.unclosed,
)
for series, faces in SERIES:
for face in faces:
row.lengths[_key(series, face)] = _length_of(slope, series, face)
if previous is not None:
distance = slope.chainage_m - previous.chainage_m
row.distance_m = distance
for key, length in row.lengths.items():
series = key.rsplit("_", 1)[0]
before = previous.lengths.get(key, 0.0)
# 평균단면적법 — 토적표와 같은 식이다(체적 대신 면적을 낸다).
row.areas[key] = (before + length) / 2.0 * distance * rates.of(series)
else:
row.areas = {key: 0.0 for key in row.lengths}
rows.append(row)
previous = row
return rows
def totals(rows: list[SlopeAreaRow]) -> dict[str, float]:
"""계열별 면적 합계. 거리(사면길이)는 합이 뜻이 없어 싣지 않는다."""
keys = [_key(series, face) for series, faces in SERIES for face in faces]
return {key: sum(row.areas.get(key, 0.0) for row in rows) for key in keys}
def unclosed_stations(rows: list[SlopeAreaRow]) -> list[float]:
"""사면이 원지반을 못 만나 **면적이 잘린** 측점 목록.
조용히 적게 내면 안 되는 값이라 화면이 이 목록을 그대로 보인다(PLAN 8-4b).
같은 사유로 토적표의 절·성토 면적도 잘려 있다.
"""
return [row.chainage_m for row in rows if row.unclosed]
def build_table(
slopes: Iterable[StationSlope], ratios: SlopeRatios | None = None
) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양."""
rates = ratios or SlopeRatios()
rows = build_rows(slopes, rates)
return {
"method": "average_end_area",
"series": [{"name": name, "faces": list(faces)} for name, faces in SERIES],
"protection_source": PROTECTION_SOURCE,
"ratios": {name: rates.of(name) for name, _ in SERIES},
"rows": [
{
"chainage_m": row.chainage_m,
"distance_m": row.distance_m,
"berm_width_m": row.berm_width_m,
"unclosed": row.unclosed,
"lengths": row.lengths,
"areas": row.areas,
}
for row in rows
],
"totals": totals(rows),
"unclosed_stations": unclosed_stations(rows),
"station_count": len(rows),
}
@@ -0,0 +1,225 @@
"""사면길이 유도 — 저장된 횡단 설계선에서 절토·성토 사면 구간을 가려낸다 (B08 일감 3).
왜 유도하나 (B06 무접촉)
설계 엔진이 `cut_slope_segments` 를 내기는 하나 **정본에 저장되지 않는다**(실측: route 150
측점 20.0 의 저장 `design` 키 33개에 그 키가 없음). 저장되는 것은 화면이 보낸 설계 지정이고
조회는 저장분을 그대로 싣는다. 그래서 그 값을 원천으로 쓰면 사면적이 조용히 0 이 된다.
대신 **`design_line`(설계선 폴리라인) + 저장된 경사비**로 유도한다. 필요한 입력이 전부
정본에 있어 B06 을 고치지 않아도 된다.
가려내는 방법
노체 끝(`road_edges`)에서 바깥으로 나아가며, 구간 기울기가 **저장된 설계 경사비와 맞는
동안**이 사면이다. 원지반은 기울기가 안 맞아 저절로 끊긴다. 2단 사면(암/토사)도 경사비가
달라 그대로 갈린다.
실측(측점 20.0, 절토 0.4 · 토사절토 1.0 · 성토 1.2, 노체 끝 ±2.0):
-2.90 → -2.60 n=1.0 측구 바깥 벽
-3.50 → -2.90 n=0.4 절토 사면(암)
-4.50 → -3.51 n=1.0 절토 사면(토사)
-5.00 → -4.85 n=1.63 원지반 — 여기서 멈춘다
⚠ 두 가지를 조심한다
· **끝 조각은 딱 안 떨어진다** — 샘플 격자에 걸려 잘리면 `n=1.025` 처럼 나온다. 허용오차를 둔다.
· **지형이 우연히 같은 경사면** 사면이 길게 잡힐 수 있다. 노체에서 바깥으로 **연속**인
구간만 세고 끊기면 멈추는 것으로 막는다.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Any, Iterable
# 경사비 일치 허용오차(비율). 끝 조각이 격자에 잘려 생기는 오차를 덮는 크기다.
_RATIO_TOLERANCE = 0.12
# 평탄부로 볼 기울기 — 소단·측구 바닥은 오름이 거의 없다.
_FLAT_RISE_M = 1e-6
@dataclass(slots=True)
class SlopeSegment:
"""사면 한 조각. `side` 는 `left`/`right`, `role` 은 `cut`/`fill`."""
side: str
role: str
from_offset_m: float
to_offset_m: float
rise_m: float
length_m: float
ratio: float
material: str | None = None
@dataclass(slots=True)
class StationSlope:
"""측점 하나의 사면길이 묶음. 면적 적분이 이 값을 거리로 쓴다."""
chainage_m: float
cut_length_m: float = 0.0
fill_length_m: float = 0.0
berm_width_m: float = 0.0
segments: tuple[SlopeSegment, ...] = ()
# 사면이 샘플 범위 끝까지 원지반을 못 만나 **면적이 잘린** 측점.
# 설계 엔진이 `slope_unclosed` 로 이미 경고하는 값을 그대로 물고 온다. 잘린 측점은
# 사면길이도 같이 잘려 있으므로 **조용히 적게 내지 말고 화면에 드러내야 한다.**
unclosed: bool = False
def _num(value: Any) -> float | None:
return float(value) if isinstance(value, (int, float)) else None
def _ratios(design: dict[str, Any]) -> dict[str, list[float]]:
"""역할별로 받아들일 경사비 목록. 2단 사면이면 암·토사 둘 다 절토로 본다."""
cut = [
value
for value in (
_num(design.get("cut_slope_ratio")),
_num(design.get("soil_cut_slope_ratio")),
)
if value is not None and value > 0
]
fill = [value for value in (_num(design.get("fill_slope_ratio")),) if value and value > 0]
return {"cut": cut, "fill": fill}
def _match(ratio: float, candidates: list[float]) -> float | None:
"""구간 경사비가 후보 중 하나와 맞으면 그 후보를 돌려준다."""
for candidate in candidates:
if abs(ratio - candidate) <= max(_RATIO_TOLERANCE * candidate, _RATIO_TOLERANCE):
return candidate
return None
def _outward(
line: list[dict[str, float]], edge_offset: float, side: str
) -> list[tuple[float, float, float, float]]:
"""노체 끝에서 **바깥으로** 향하는 구간 목록 `(시작오프셋, 끝오프셋, run, rise)`.
좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다(설계선 좌표 관례).
"""
points = sorted(
((float(p["offset_m"]), float(p["elevation_m"])) for p in line), key=lambda p: p[0]
)
if side == "left":
outer = [p for p in points if p[0] >= edge_offset]
else:
outer = [p for p in points if p[0] <= edge_offset][::-1]
return [
(
outer[i - 1][0],
outer[i][0],
abs(outer[i][0] - outer[i - 1][0]),
outer[i][1] - outer[i - 1][1],
)
for i in range(1, len(outer))
]
def slope_start_offset(design: dict[str, Any], side: str) -> float | None:
"""사면이 시작하는 오프셋 — 노체 끝, 측구가 있으면 **측구 바깥 끝**.
⚠ 이것이 없으면 **측구 바깥 벽이 사면으로 잡힌다.** 실측(측점 20.0)에서 측구 벽 경사가
n=1.0 으로 토사 절토비와 같아 그대로 걸렸다. 측구는 노체 배수 시설이지 사면이 아니므로
그 바깥 끝에서부터 세어야 한다.
"""
edges = design.get("road_edges") or {}
edge = _num((edges.get(side) or {}).get("offset_m"))
if edge is None:
return None
if not design.get("ditch_enabled"):
return edge
ditch_side = design.get("ditch_side")
if ditch_side not in (side, "both", None):
return edge
width = _num((design.get("ditch") or {}).get("top_width_m")) or 0.0
# 좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다.
return edge + width if side == "left" else edge - width
def _side_segments(
design: dict[str, Any], side: str, ratios: dict[str, list[float]]
) -> list[SlopeSegment]:
"""한쪽 사면 구간 목록. 경사비가 안 맞는 구간을 만나면 거기서 멈춘다."""
line = design.get("design_line") or []
edge = slope_start_offset(design, side)
if not line or edge is None:
return []
berm = design.get("berm") or {}
berm_width = _num(berm.get("width_m")) or 0.0
segments: list[SlopeSegment] = []
started = False
for start, end, run, rise in _outward(line, float(edge), side):
if run <= 1e-9:
continue
if abs(rise) <= _FLAT_RISE_M:
# 평탄부 — 측구 바닥·소단. 사면이 시작된 뒤라면 소단으로 보고 이어 간다.
if started and berm_width > 0 and abs(run - berm_width) < 0.05:
continue
if started:
break # 사면이 끝나고 평지를 만난 것이다
continue
ratio = run / abs(rise)
# 절토는 바깥으로 갈수록 오르고, 성토는 내려간다.
role = "cut" if rise > 0 else "fill"
matched = _match(ratio, ratios[role])
if matched is None:
if started:
break # 원지반에 닿았다
continue # 아직 노체·측구 구간이다
started = True
segments.append(
SlopeSegment(
side=side,
role=role,
from_offset_m=start,
to_offset_m=end,
rise_m=rise,
length_m=math.hypot(run, rise),
ratio=matched,
material=_material(design, matched),
)
)
return segments
def _material(design: dict[str, Any], ratio: float) -> str | None:
"""경사비로 재료를 가른다 — 그린 대로 적는다(B06 `cut_slope_segments` 주석과 같은 규칙)."""
if not design.get("two_stage_slope"):
return None
rock = _num(design.get("cut_slope_ratio"))
soil = _num(design.get("soil_cut_slope_ratio"))
if rock is None or soil is None or abs(rock - soil) < 1e-9:
return None
return "rock" if abs(ratio - rock) < abs(ratio - soil) else "soil"
def station_slope(chainage_m: float, design: dict[str, Any]) -> StationSlope:
"""측점 하나의 사면길이. 좌우를 합쳐 절토·성토 각각의 총 사면길이를 낸다."""
ratios = _ratios(design)
segments: list[SlopeSegment] = []
for side in ("left", "right"):
segments.extend(_side_segments(design, side, ratios))
berm = design.get("berm") or {}
return StationSlope(
chainage_m=float(chainage_m),
cut_length_m=sum(s.length_m for s in segments if s.role == "cut"),
fill_length_m=sum(s.length_m for s in segments if s.role == "fill"),
berm_width_m=_num(berm.get("width_m")) or 0.0,
segments=tuple(segments),
unclosed=bool(design.get("slope_unclosed")),
)
def station_slopes(records: Iterable[dict[str, Any]]) -> list[StationSlope]:
"""`[{chainage_m, design}]` → 측점별 사면길이. 이정 순으로 낸다."""
result = [
station_slope(item["chainage_m"], item.get("design") or {})
for item in records
if item.get("chainage_m") is not None
]
result.sort(key=lambda s: s.chainage_m)
return result
+110 -1
View File
@@ -12,18 +12,35 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B06_Section.B06_Section_Repository import ( from B06_Section.B06_Section_Repository import (
get_cross_section_designs, get_cross_section_designs,
get_longitudinal_section,
get_workflow_route_context, get_workflow_route_context,
) )
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table
from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import summary_input_rows
from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table
from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes
from common_util.common_util_project_settings import (
application_ratio,
quantity_settings,
rock_classes,
save_section,
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import run_with_connection from config.config_db import run_with_connection
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,7 +55,11 @@ def _stations(designs: list[dict[str, Any]]) -> list[StationArea]:
@router.get("/{project_id}/quantity/{route_id}/earthwork-table") @router.get("/{project_id}/quantity/{route_id}/earthwork-table")
async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
"""토적표 — 측점별 단면적을 평균단면적법으로 체적화한 표.""" """토적표 — 토공(체적)과 사면 4계열(면적)을 **한 응답**으로 낸다.
실무 토적표가 한 장이라 화면도 한 장이다. 나눠 부르면 두 번 왕복하고, 같은 측점 목록을
두 벌로 들게 된다.
"""
try: try:
designs = await run_with_connection(get_cross_section_designs, route_id) designs = await run_with_connection(get_cross_section_designs, route_id)
except Exception: except Exception:
@@ -48,10 +69,98 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
content={"status": "error", "message": "토적표를 만들지 못했습니다."}, content={"status": "error", "message": "토적표를 만들지 못했습니다."},
) )
table = build_table(_stations(designs)) table = build_table(_stations(designs))
# 사면 계열은 저장된 설계선에서 유도한다.
slope = build_slope_table(station_slopes(designs))
table["slope"] = slope
settings, project_root = await _project_settings(project_id)
plan = await _stored_haul_plan(project_id, route_id)
haul = build_haul_table(plan)
table["haul"] = haul
# 운반계획은 [저장]·[확정]에서 정본에 남는 값이다 — 아직 없으면 빈 표가 정직하다.
table["haul_available"] = bool(plan)
table["summary"] = build_summary_table(
SummaryInput(
earthwork_totals=table.get("totals") or {},
slope_totals=slope.get("totals") or {},
haul_rows=summary_input_rows(haul),
rock_classes=rock_classes(settings),
rock_ratios_pct=settings.get("rock_ratios_pct") or {},
application_ratios={
key: application_ratio(settings, key)
for key in (settings.get("application_ratios_pct") or {})
},
)
)
table["settings"] = settings
table["project_root_known"] = project_root is not None
table["route_id"] = route_id table["route_id"] = route_id
return JSONResponse(content=table) return JSONResponse(content=table)
async def _project_settings(project_id: UUID) -> tuple[dict[str, Any], str | None]:
"""프로젝트 설정을 읽는다. 경로를 못 찾아도 기본값으로 화면은 선다."""
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
root = resolve_stored_project_path(stored_path)
except Exception:
logger.warning("B08 프로젝트 경로를 못 찾음: project_id=%s", project_id)
from common_util.common_util_project_settings import default_settings
return default_settings()["quantity"], None
return quantity_settings(root), root
async def _stored_haul_plan(project_id: UUID, route_id: int) -> dict[str, Any] | None:
"""정본에 남은 운반계획. [확정]을 아직 안 돌렸으면 없다."""
try:
row = await run_with_connection(get_longitudinal_section, project_id, route_id)
except Exception:
logger.exception("B08 운반계획 조회 실패: route_id=%s", route_id)
return None
data = (row or {}).get("data") or {}
plan = data.get("mass_haul") if isinstance(data, dict) else None
return plan if isinstance(plan, dict) and plan else None
class QuantitySettingsBody(BaseModel):
"""[저장]이 보내는 산출 조건. 보내지 않은 칸은 저장분을 그대로 둔다."""
rock_class_set: str | None = None
rock_classes: list[str] | None = None
rock_ratios_pct: dict[str, float] | None = None
application_ratios_pct: dict[str, float] | None = None
@router.put("/{project_id}/quantity/settings")
async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -> JSONResponse:
"""산출 조건을 정본에 남긴다 — [저장]이 부르는 자리.
⚠ 자동저장이 아니다(CLAUDE.md 5장). 조작은 캐시에 쌓이고 여기서만 작업본으로 넘어간다.
⚠ `quantity` 구획만 쓴다 — `estimation` 은 B09 것이라 손대지 않는다(모듈이 막고 있다).
"""
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
root = resolve_stored_project_path(stored_path)
except Exception:
logger.exception("B08 설정 저장 실패(경로): project_id=%s", project_id)
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
values = {key: value for key, value in body.model_dump().items() if value is not None}
try:
saved = await asyncio.to_thread(save_section, root, "quantity", values)
except Exception:
logger.exception("B08 설정 저장 실패(쓰기): project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "산출 조건을 저장하지 못했습니다."},
)
return JSONResponse(content={"status": "success", "quantity": saved.get("quantity") or {}})
@router.get("/{project_id}/quantity/earthwork-table") @router.get("/{project_id}/quantity/earthwork-table")
async def get_earthwork_table_for_current_route(project_id: UUID) -> JSONResponse: async def get_earthwork_table_for_current_route(project_id: UUID) -> JSONResponse:
"""경로를 안 주면 워크플로가 보고 있는 경로로 낸다 — 화면이 route_id 를 모를 때 쓴다.""" """경로를 안 주면 워크플로가 보고 있는 경로로 낸다 — 화면이 route_id 를 모를 때 쓴다."""
+177 -3
View File
@@ -36,12 +36,45 @@ export interface EarthworkRow {
cumulative_m3: number; cumulative_m3: number;
} }
/** 사면 4계열 — 계열별 (거리, 면적). 키는 `면고르기_성토면` 식으로 엔진과 같다. */
export interface SlopeRow {
chainage_m: number;
distance_m: number;
berm_width_m: number;
unclosed: boolean;
lengths: Record<string, number>;
areas: Record<string, number>;
}
export interface SlopeTable {
rows: SlopeRow[];
totals: Record<string, number>;
ratios: Record<string, number>;
unclosed_stations: number[];
}
/** 산출 조건 — `project_settings.json` 의 `quantity` 구획. */
export interface QuantitySettings {
rock_class_set?: string;
rock_classes?: string[];
rock_ratios_pct?: Record<string, number>;
application_ratios_pct?: Record<string, number>;
}
export interface EarthworkTable { export interface EarthworkTable {
method: string; method: string;
station_count: number; station_count: number;
route_id?: number; route_id?: number;
rows: EarthworkRow[]; rows: EarthworkRow[];
totals: Record<string, number>; totals: Record<string, number>;
conversion_factors?: Record<string, { compacted: number }>;
slope?: SlopeTable;
/** 토공집계표·운반표는 같은 응답에 실려 온다 — 나눠 부르지 않는다. */
summary?: import("./B08_Quantity_UI_SummaryGrid").SummaryTable;
haul?: import("./B08_Quantity_UI_SummaryGrid").HaulTable;
/** 운반계획은 [저장]·[확정]에서 정본에 남는 값 — 아직 없으면 false. */
haul_available?: boolean;
settings?: QuantitySettings;
} }
/** 열 하나. `digits` 는 **표기 자리**이며 값 자체는 자르지 않는다. */ /** 열 하나. `digits` 는 **표기 자리**이며 값 자체는 자르지 않는다. */
@@ -119,12 +152,46 @@ const GROUPS: { label: string; sub: { label: string; cols: Column[] }[] }[] = [
{ label: "", sub: [{ label: "누가토량", cols: [{ key: "cumulative_m3", digits: 1 }] }] }, { label: "", sub: [{ label: "누가토량", cols: [{ key: "cumulative_m3", digits: 1 }] }] },
]; ];
/** 사면 4계열 — 실무 토적표 오른쪽 절반(V~AI). 계열마다 (거리, 면적) 쌍이다.
* 키는 엔진과 같은 이름을 쓴다 — 이름이 어긋나면 값이 조용히 빈다. */
const SLOPE_GROUPS: { label: string; faces: { key: string; label: string }[] }[] = [
{ label: "층 따 기", faces: [{ key: "bench_cut_fill", label: "성 토 면" }] },
{
label: "면고르기",
faces: [
{ key: "face_dressing_fill", label: "성 토 면" },
{ key: "face_dressing_cut", label: "절 토 면" },
],
},
{
label: "법 면 보 호 공",
faces: [
{ key: "slope_protection_fill", label: "종자파종(성토)" },
{ key: "slope_protection_cut", label: "종자파종(절토)" },
],
},
{
label: "지 장 목 제 거",
faces: [
{ key: "tree_removal_fill", label: "성 토 면" },
{ key: "tree_removal_cut", label: "절 토 면" },
],
},
];
/** 사면 계열의 소분류 머리글 — 거리(사면길이)와 면적 두 칸. */
const SLOPE_LABELS = ["거 리", "면 적"];
/** 소분류가 「단면적·입적·보정량」 삼중조일 때 붙는 3단 머리글 문구. */ /** 소분류가 「단면적·입적·보정량」 삼중조일 때 붙는 3단 머리글 문구. */
const TRIPLE_LABELS = ["단면적", "입 적", "보정량"]; const TRIPLE_LABELS = ["단면적", "입 적", "보정량"];
const PAIR_LABELS = ["단면적", "입 적"]; const PAIR_LABELS = ["단면적", "입 적"];
const flatColumns = (): Column[] => GROUPS.flatMap((g) => g.sub.flatMap((s) => s.cols)); const flatColumns = (): Column[] => GROUPS.flatMap((g) => g.sub.flatMap((s) => s.cols));
/** 사면 열 개수 — 계열마다 (거리, 면적) 두 칸. */
const slopeColumnCount = (): number =>
SLOPE_GROUPS.reduce((n, group) => n + group.faces.length * 2, 0);
/** 측점 표기 — `20` → `NO.1`, `25` → `NO.1+5`. 실무 토적표가 이 모양이다. */ /** 측점 표기 — `20` → `NO.1`, `25` → `NO.1+5`. 실무 토적표가 이 모양이다. */
function stationLabel(chainage: number, interval = 20): string { function stationLabel(chainage: number, interval = 20): string {
const no = Math.floor(chainage / interval); const no = Math.floor(chainage / interval);
@@ -178,13 +245,35 @@ function buildHead(): HTMLTableSectionElement {
r1.append(th); r1.append(th);
} }
} }
// 사면 4계열 — 대분류 / 면(성토·절토) / (거리·면적) 3단으로 같은 모양을 이어 붙인다.
for (const group of SLOPE_GROUPS) {
const th = document.createElement("th");
th.colSpan = group.faces.length * 2;
th.textContent = group.label;
r1.append(th);
for (const face of group.faces) {
const th2 = document.createElement("th");
th2.colSpan = 2;
th2.textContent = face.label;
r2.append(th2);
for (const label of SLOPE_LABELS) {
const th3 = document.createElement("th");
th3.textContent = label;
r3.append(th3);
}
}
}
head.append(r1, r2, r3); head.append(r1, r2, r3);
return head; return head;
} }
function buildBody(rows: EarthworkRow[]): HTMLTableSectionElement { function buildBody(rows: EarthworkRow[], slope?: SlopeTable): HTMLTableSectionElement {
const body = document.createElement("tbody"); const body = document.createElement("tbody");
const columns = flatColumns(); const columns = flatColumns();
const slopeByChainage = new Map((slope?.rows ?? []).map((row) => [row.chainage_m, row]));
for (const row of rows) { for (const row of rows) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
columns.forEach((column, index) => { columns.forEach((column, index) => {
@@ -194,12 +283,25 @@ function buildBody(rows: EarthworkRow[]): HTMLTableSectionElement {
if (index === 0) td.className = "b08-grid__station"; if (index === 0) td.className = "b08-grid__station";
tr.append(td); tr.append(td);
}); });
const slopeRow = slopeByChainage.get(row.chainage_m);
// 사면이 원지반을 못 만난 측점은 값이 잘려 있다 — 줄에 표시를 남긴다(PLAN 8-4b).
if (slopeRow?.unclosed) tr.classList.add("is-unclosed");
for (const group of SLOPE_GROUPS) {
for (const face of group.faces) {
for (const source of [slopeRow?.lengths, slopeRow?.areas]) {
const td = document.createElement("td");
td.textContent = cell(source?.[face.key], 1);
tr.append(td);
}
}
}
body.append(tr); body.append(tr);
} }
return body; return body;
} }
function buildFoot(totals: Record<string, number>): HTMLTableSectionElement { function buildFoot(totals: Record<string, number>, slope?: SlopeTable): HTMLTableSectionElement {
const foot = document.createElement("tfoot"); const foot = document.createElement("tfoot");
const tr = document.createElement("tr"); const tr = document.createElement("tr");
flatColumns().forEach((column, index) => { flatColumns().forEach((column, index) => {
@@ -208,10 +310,73 @@ function buildFoot(totals: Record<string, number>): HTMLTableSectionElement {
else if (column.sum) td.textContent = cell(totals[column.key], column.digits); else if (column.sum) td.textContent = cell(totals[column.key], column.digits);
tr.append(td); tr.append(td);
}); });
// 사면은 면적만 합한다 — 거리(사면길이)는 합이 뜻이 없다.
for (const group of SLOPE_GROUPS) {
for (const face of group.faces) {
tr.append(document.createElement("td"));
const td = document.createElement("td");
td.textContent = cell(slope?.totals?.[face.key], 1);
tr.append(td);
}
}
foot.append(tr); foot.append(tr);
return foot; return foot;
} }
/** 잘린 측점 안내 — 한 덩어리로 묶고, 목록은 접어 둔다.
*
* 왜 붉은 오류가 아닌가
* 실측 발생률이 21~26 %(랩탑 route 169 는 22/105, 이 노선은 17/65)라 **늘 뜨는 안내**다.
* 매번 요란하면 곧 무시당한다. 그래서 **주의 표시 + 접히는 목록**으로 둔다.
*
* 왜 한 덩어리인가
* 절·성토 면적 · 사면적 · 사면길이가 **전부 같은 사유로** 잘린다. 항목마다 따로 띄우면
* 사용자가 세 번 읽게 된다.
*
* 왜 안 넓히나 (B06 담당 확인, 2026-09-07)
* 미교차의 절반 이상이 계곡·절벽처럼 **지형이 설계 사면에서 멀어지는 자리**라 반폭을
* 늘려도 영원히 안 닫힌다. 닫히는 쪽도 중앙값 +3m 인데 꼬리가 +292m 이라 전역 확대는
* 값이 안 나온다. 그래서 경고로 대체한다(2026-09-03 사용자 확정).
*/
function buildUnclosedNotice(slope: SlopeTable, table: HTMLTableElement): HTMLElement | null {
const stations = slope.unclosed_stations ?? [];
if (!stations.length) return null;
const box = document.createElement("details");
box.className = "b08-grid__warning";
const summary = document.createElement("summary");
summary.className = "b08-grid__warning-summary";
summary.textContent =
`주의 — ${stations.length}개 측점에서 사면이 원지반을 만나지 못했습니다. ` +
"그 측점의 절·성토 면적 · 사면길이 · 사면적이 함께 잘려 있어 실제보다 작습니다.";
box.append(summary);
const list = document.createElement("div");
list.className = "b08-grid__warning-list";
for (const chainage of stations) {
const link = document.createElement("button");
link.type = "button";
link.className = "b08-grid__warning-station";
link.textContent = stationLabel(chainage);
link.addEventListener("click", () => {
const row = table.querySelector<HTMLElement>(
`tbody tr:nth-child(${slopeRowIndex(slope, chainage) + 1})`,
);
row?.scrollIntoView({ block: "center", behavior: "smooth" });
row?.classList.add("is-highlighted");
window.setTimeout(() => row?.classList.remove("is-highlighted"), 1600);
});
list.append(link);
}
box.append(list);
return box;
}
function slopeRowIndex(slope: SlopeTable, chainage: number): number {
return slope.rows.findIndex((row) => row.chainage_m === chainage);
}
/** 토적표 하나를 그린다. 넓은 표라 스스로 가로 스크롤한다. */ /** 토적표 하나를 그린다. 넓은 표라 스스로 가로 스크롤한다. */
export function renderEarthworkGrid(table: EarthworkTable): HTMLElement { export function renderEarthworkGrid(table: EarthworkTable): HTMLElement {
const wrap = document.createElement("div"); const wrap = document.createElement("div");
@@ -226,7 +391,16 @@ export function renderEarthworkGrid(table: EarthworkTable): HTMLElement {
scroller.className = "b08-grid__scroll"; scroller.className = "b08-grid__scroll";
const element = document.createElement("table"); const element = document.createElement("table");
element.className = "b08-grid__table"; element.className = "b08-grid__table";
element.append(buildHead(), buildBody(table.rows), buildFoot(table.totals)); element.append(
buildHead(),
buildBody(table.rows, table.slope),
buildFoot(table.totals, table.slope),
);
if (table.slope) {
const notice = buildUnclosedNotice(table.slope, element);
if (notice) wrap.append(notice);
}
scroller.append(element); scroller.append(element);
wrap.append(scroller); wrap.append(scroller);
return wrap; return wrap;
@@ -63,6 +63,45 @@ const CSS = `
font-weight: var(--font-weight-medium, 600); font-weight: var(--font-weight-medium, 600);
} }
/* 잘린 측점 안내 — 발생률이 21~26 % 로 늘 뜨는 것이라 붉은 오류가 아니라 **주의**로 둔다.
요란하면 곧 무시당한다. 목록은 접어 두고 필요할 때만 편다. */
.b08-grid__warning {
padding: 6px 10px;
font-size: 12px;
color: var(--color-text-body);
background: var(--color-surface-raised);
border-left: 3px solid var(--color-text-muted);
}
.b08-grid__warning-summary { cursor: pointer; }
.b08-grid__warning-list {
display: flex;
flex-wrap: wrap;
gap: 4px 6px;
padding-top: 6px;
}
.b08-grid__warning-station {
font-size: 11px;
padding: 0 6px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
font-variant-numeric: tabular-nums;
}
/* 잘린 줄은 표에서도 알아보게 왼쪽에 표시를 남긴다 — 눈에 띄되 요란하지 않게. */
.b08-grid__table tbody tr.is-unclosed .b08-grid__station {
border-left: 3px solid var(--color-text-muted);
}
.b08-grid__table tbody tr.is-highlighted td {
background: var(--color-royal-amethyst, #d8ccff);
color: #1b2220;
}
.b08-quantity__tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--color-border); } .b08-quantity__tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--color-border); }
.b08-quantity__tab { .b08-quantity__tab {
@@ -82,6 +121,35 @@ const CSS = `
} }
.b08-quantity__body { display: flex; flex-direction: column; gap: 8px; padding: 8px; min-height: 0; flex: 1 1 auto; } .b08-quantity__body { display: flex; flex-direction: column; gap: 8px; padding: 8px; min-height: 0; flex: 1 1 auto; }
.b08-quantity__pane { display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto; }
/* 집계·운반표는 열이 적어 왼쪽 정렬이 읽기 좋다 — 숫자 칸만 오른쪽으로 둔다. */
.b08-grid__table--summary th,
.b08-grid__table--summary td { text-align: left; }
.b08-grid__table--summary td:nth-child(5),
.b08-grid__table--summary td:nth-child(4) { text-align: right; }
.b08-grid__unit { text-align: center; }
.b08-grid__note { white-space: normal; max-width: 26rem; }
/* 「내역 제외」 같은 표시 — 규칙이 코드에만 있으면 잊힌다. 화면에 남긴다. */
.b08-grid__tag {
display: inline-block;
margin-right: 4px;
padding: 0 6px;
font-size: 11px;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
}
.b08-quantity__input {
width: 5rem;
font-size: 12px;
text-align: right;
font-variant-numeric: tabular-nums;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.b08-quantity__message { margin: 0; padding: 16px; font-size: 13px; color: var(--color-text-secondary); } .b08-quantity__message { margin: 0; padding: 16px; font-size: 13px; color: var(--color-text-secondary); }
.b08-quantity__field { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; padding: 2px 0; } .b08-quantity__field { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; padding: 2px 0; }
.b08-quantity__field-value { color: var(--color-text-secondary); font-variant-numeric: tabular-nums; } .b08-quantity__field-value { color: var(--color-text-secondary); font-variant-numeric: tabular-nums; }
+173 -18
View File
@@ -19,6 +19,7 @@ import {
} from "../A00_Common/b_workflow_nav"; } from "../A00_Common/b_workflow_nav";
import { renderEarthworkGrid, type EarthworkTable } from "./B08_Quantity_UI_EarthworkGrid"; import { renderEarthworkGrid, type EarthworkTable } from "./B08_Quantity_UI_EarthworkGrid";
import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style"; import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style";
import { renderHaulGrid, renderSummaryGrid } from "./B08_Quantity_UI_SummaryGrid";
/** locale 헬퍼 */ /** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string { function L(key: keyof typeof ui_locales): string {
@@ -46,6 +47,24 @@ async function fetchEarthworkTable(projectId: string): Promise<EarthworkTable> {
return (await response.json()) as EarthworkTable; return (await response.json()) as EarthworkTable;
} }
/** [저장] — 산출 조건을 정본에 남긴다. `quantity` 구획만 간다(서버가 막고 있다). */
async function saveQuantitySettings(projectId: string, draft: DraftSettings): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/settings`,
{
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
rock_class_set: draft.rock_class_set ?? null,
rock_ratios_pct: draft.rock_ratios_pct,
application_ratios_pct: draft.application_ratios_pct,
}),
},
);
if (!response.ok) throw new Error(`quantity settings save failed: ${response.status}`);
}
/** 좌측 패널의 한 줄 — 이름과 값. 산출 조건을 읽기 전용으로 보인다. */ /** 좌측 패널의 한 줄 — 이름과 값. 산출 조건을 읽기 전용으로 보인다. */
function field(label: string, value: string): HTMLElement { function field(label: string, value: string): HTMLElement {
const row = document.createElement("div"); const row = document.createElement("div");
@@ -59,10 +78,43 @@ function field(label: string, value: string): HTMLElement {
return row; return row;
} }
/** 좌측 패널: 산출 조건(읽기 전용) + 하단 [확정] 액션 행. */ /** 반영률·비율 입력 한 칸. 값은 **캐시에만** 쌓이고 [저장]에서 정본으로 간다(5장). */
function numberField(
label: string,
value: number,
onInput: (value: number) => void,
): HTMLElement {
const row = document.createElement("label");
row.className = "b08-quantity__field";
const name = document.createElement("span");
name.textContent = label;
const input = document.createElement("input");
input.type = "number";
input.className = "b08-quantity__input";
input.min = "0";
input.step = "1";
input.value = String(value);
// 자동저장은 만들지 않는다 — 입력은 캐시에만 남는다(CLAUDE.md 5장).
input.addEventListener("input", () => onInput(Number(input.value)));
row.append(name, input);
return row;
}
/** 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다. */
interface DraftSettings {
rock_class_set?: string;
rock_ratios_pct: Record<string, number>;
application_ratios_pct: Record<string, number>;
dirty: boolean;
}
/** 좌측 패널: 산출 조건 + 하단 [저장]·[확정] 액션 행.
* `reload` 는 저장 뒤 표를 다시 그리는 손잡이다 — 조건이 바뀌면 집계·운반 값이 달라진다. */
function buildQuantitySidePanel( function buildQuantitySidePanel(
projectId: string | null, projectId: string | null,
table: EarthworkTable | null, table: EarthworkTable | null,
draft: DraftSettings,
reload: () => void,
): HTMLElement { ): HTMLElement {
const panel = document.createElement("div"); const panel = document.createElement("div");
panel.className = "b08-quantity__panel"; panel.className = "b08-quantity__panel";
@@ -77,6 +129,57 @@ function buildQuantitySidePanel(
} }
} }
// ── 지반 구성비 — 갈래 수는 프로젝트 세트가 정한다(코드에 안 박음, PLAN 8-13) ──
const classes = table?.summary?.rock_classes ?? [];
if (classes.length) {
panel.append(field(L("B08_Quantity_Side_RockRatios"), ""));
for (const name of classes) {
panel.append(
numberField(name, draft.rock_ratios_pct[name] ?? 0, (value) => {
draft.rock_ratios_pct[name] = value;
draft.dirty = true;
}),
);
}
}
// ── 반영률 — 기본 100 %. 실무 관측 80/50/80 은 기본값이 아니다(PLAN 8-11) ──
const ratios = table?.settings?.application_ratios_pct ?? {};
if (Object.keys(ratios).length) {
panel.append(field(L("B08_Quantity_Side_Ratios"), ""));
for (const key of Object.keys(ratios)) {
panel.append(
numberField(key, draft.application_ratios_pct[key] ?? 100, (value) => {
draft.application_ratios_pct[key] = value;
draft.dirty = true;
}),
);
}
}
const saveButton = createButton({
label: L("B08_Quantity_Btn_Save"),
variant: "outlined",
onClick: () => {
if (!projectId) {
showToast(L("B08_Quantity_Save_Failed"), "error");
return;
}
saveButton.disabled = true;
saveQuantitySettings(projectId, draft)
.then(() => {
draft.dirty = false;
showToast(L("B08_Quantity_Save_Success"), "success");
// 조건이 바뀌면 집계·운반 값이 달라진다 — 표를 다시 받아 그린다.
reload();
})
.catch(() => {
showToast(L("B08_Quantity_Save_Failed"), "error");
saveButton.disabled = false;
});
},
});
const confirmButton = createButton({ const confirmButton = createButton({
label: L("B08_Quantity_Btn_Confirm"), label: L("B08_Quantity_Btn_Confirm"),
variant: "filled", variant: "filled",
@@ -105,35 +208,67 @@ function buildQuantitySidePanel(
return panel; return panel;
} }
/** 우측 본문 — 시트 탭 + 그 장의 표. 지금 서 있는 장은 토적표 하나다. */ /** 우측 본문 — 시트 탭 + 고른 장의 표. 실무 산출서의 시트를 탭으로 옮긴 것이다. */
function buildQuantityBody(table: EarthworkTable | null, failed: boolean): HTMLElement { function buildQuantityBody(table: EarthworkTable | null, failed: boolean): HTMLElement {
const body = document.createElement("div"); const body = document.createElement("div");
body.className = "b08-quantity__body"; body.className = "b08-quantity__body";
const tabs = document.createElement("div"); const tabs = document.createElement("div");
tabs.className = "b08-quantity__tabs"; tabs.className = "b08-quantity__tabs";
const tab = document.createElement("button"); const pane = document.createElement("div");
tab.type = "button"; pane.className = "b08-quantity__pane";
tab.className = "b08-quantity__tab is-active";
tab.textContent = L("B08_Quantity_Tab_Earthwork"); const message = (text: string): HTMLElement => {
tabs.append(tab); const element = document.createElement("p");
body.append(tabs); element.className = "b08-quantity__message";
element.textContent = text;
return element;
};
if (failed) { if (failed) {
const message = document.createElement("p"); body.append(tabs, message(L("B08_Quantity_Grid_Failed")));
message.className = "b08-quantity__message";
message.textContent = L("B08_Quantity_Grid_Failed");
body.append(message);
return body; return body;
} }
if (!table || !table.rows?.length) { if (!table || !table.rows?.length) {
const message = document.createElement("p"); body.append(tabs, message(L("B08_Quantity_Grid_Empty")));
message.className = "b08-quantity__message";
message.textContent = L("B08_Quantity_Grid_Empty");
body.append(message);
return body; return body;
} }
body.append(renderEarthworkGrid(table));
const sheets: { label: string; build: () => HTMLElement }[] = [
{ label: L("B08_Quantity_Tab_Earthwork"), build: () => renderEarthworkGrid(table) },
{
label: L("B08_Quantity_Tab_Summary"),
build: () =>
table.summary
? renderSummaryGrid(table.summary)
: message(L("B08_Quantity_Grid_Empty")),
},
{
label: L("B08_Quantity_Tab_Haul"),
build: () =>
table.haul
? renderHaulGrid(table.haul, Boolean(table.haul_available))
: message(L("B08_Quantity_Haul_Missing")),
},
];
const buttons: HTMLButtonElement[] = [];
const show = (index: number): void => {
buttons.forEach((button, i) => button.classList.toggle("is-active", i === index));
pane.replaceChildren(sheets[index].build());
};
sheets.forEach((sheet, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "b08-quantity__tab";
button.textContent = sheet.label;
button.addEventListener("click", () => show(index));
buttons.push(button);
tabs.append(button);
});
body.append(tabs, pane);
show(0);
return body; return body;
} }
@@ -155,6 +290,26 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
} }
} }
// 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다(CLAUDE.md 5장).
const stored = table?.settings ?? {};
const draft: DraftSettings = {
rock_class_set: stored.rock_class_set,
rock_ratios_pct: { ...(stored.rock_ratios_pct ?? {}) },
application_ratios_pct: { ...(stored.application_ratios_pct ?? {}) },
dirty: false,
};
const reload = (): void => {
root.replaceChildren();
void renderB08Quantity(root);
};
// 저장 안 한 값이 조용히 사라지지 않게 나갈 때 알린다 — 이 구조의 대가다.
const warnUnsaved = (event: BeforeUnloadEvent): void => {
if (!draft.dirty) return;
event.preventDefault();
event.returnValue = L("B08_Quantity_Unsaved");
};
window.addEventListener("beforeunload", warnUnsaved);
let workflowState: Awaited<ReturnType<typeof fetchWorkflowState>> | undefined; let workflowState: Awaited<ReturnType<typeof fetchWorkflowState>> | undefined;
if (projectId) { if (projectId) {
try { try {
@@ -168,7 +323,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
title: L("B08_Quantity_Title"), title: L("B08_Quantity_Title"),
steps: workflowSteps(), steps: workflowSteps(),
activeStep: 5, activeStep: 5,
leftPanel: buildQuantitySidePanel(projectId, table), leftPanel: buildQuantitySidePanel(projectId, table, draft, reload),
mainContent: buildQuantityBody(table, failed), mainContent: buildQuantityBody(table, failed),
stages: workflowState?.stages, stages: workflowState?.stages,
currentStage: workflowState?.current_stage, currentStage: workflowState?.current_stage,
+176
View File
@@ -0,0 +1,176 @@
/* =============================================================================
* B08_Quantity_UI_SummaryGrid.ts
* 토공집계표·운반거리 그리드 (PLAN 8-11·8-3).
*
* 토공집계표 열은 거창 실무 시트 그대로 — 구분·공종·규격·단위·계·비고.
* 비고에는 **설계자가 정한 값만** 남는다(반영률을 바꿨을 때·비율 합이 100 이 아닐 때).
* 기본값 그대로면 비워 둔다 — 안내가 매번 뜨면 잡음이 된다.
*
* ⚠ 무대(소운반 20m)는 집계에는 오르되 **내역 줄이 아니다**(품셈 1-2-7). 그 줄에
* 「내역 제외」를 붙여 화면에서도 보이게 한다 — 규칙이 코드에만 있으면 잊힌다.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export interface SummaryRow {
group: string;
item: string;
spec: string;
unit: string;
amount: number;
note: string;
in_bill: boolean;
}
export interface SummaryTable {
columns: string[];
rows: SummaryRow[];
rock_classes: string[];
}
export interface HaulRow {
equipment: string;
ground: string;
volume_m3: number;
average_distance_m: number;
legs: number;
in_bill: boolean;
}
export interface HaulTable {
rows: HaulRow[];
legs: { equipment: string; ground: string; volume_m3: number; distance_m: number; from_m: number; to_m: number }[];
bill_row_count: number;
}
/** 운반수단 표기 — 서버 키를 실무 시트 문구로. */
const HAUL_LABELS: Record<string, string> = {
free_haul: "무대(종방향유용토)",
dozer: "도자운반",
dump_truck: "덤프운반",
};
function num(value: number | undefined, digits: number): string {
if (value === undefined || value === null || Number.isNaN(value) || value === 0) return "";
return value.toLocaleString("ko-KR", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
function textCell(text: string, className?: string): HTMLTableCellElement {
const td = document.createElement("td");
td.textContent = text;
if (className) td.className = className;
return td;
}
/** 토공집계표 — 실무 시트와 같은 여섯 열. */
export function renderSummaryGrid(table: SummaryTable): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b08-grid";
const scroller = document.createElement("div");
scroller.className = "b08-grid__scroll";
const element = document.createElement("table");
element.className = "b08-grid__table b08-grid__table--summary";
const head = document.createElement("thead");
const headRow = document.createElement("tr");
for (const label of table.columns) {
const th = document.createElement("th");
th.textContent = label;
headRow.append(th);
}
head.append(headRow);
const body = document.createElement("tbody");
let lastGroup = "";
for (const row of table.rows) {
const tr = document.createElement("tr");
// 같은 구분이 이어지면 한 번만 적는다 — 실무 시트가 그렇게 병합해 둔다.
tr.append(textCell(row.group === lastGroup ? "" : row.group, "b08-grid__station"));
lastGroup = row.group;
tr.append(textCell(row.item));
tr.append(textCell(row.spec));
tr.append(textCell(row.unit, "b08-grid__unit"));
tr.append(textCell(num(row.amount, row.unit === "㎥" ? 2 : 1)));
const note = textCell(row.note, "b08-grid__note");
if (!row.in_bill) {
const tag = document.createElement("span");
tag.className = "b08-grid__tag";
tag.textContent = L("B08_Quantity_Haul_Excluded");
note.prepend(tag);
}
tr.append(note);
body.append(tr);
}
element.append(head, body);
scroller.append(element);
wrap.append(scroller);
return wrap;
}
/** 운반거리 — 내역 줄(가중평균)과 근거 줄을 나눠 보인다. */
export function renderHaulGrid(table: HaulTable, available: boolean): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b08-grid";
if (!available || !table.rows.length) {
const message = document.createElement("p");
message.className = "b08-quantity__message";
message.textContent = L("B08_Quantity_Haul_Missing");
wrap.append(message);
return wrap;
}
const caption = document.createElement("p");
caption.className = "b08-grid__caption";
caption.textContent = `내역 줄 ${table.bill_row_count}개 · 근거 구간 ${table.legs.length}개 · 토량 가중평균`;
wrap.append(caption);
const scroller = document.createElement("div");
scroller.className = "b08-grid__scroll";
const element = document.createElement("table");
element.className = "b08-grid__table b08-grid__table--summary";
const head = document.createElement("thead");
const headRow = document.createElement("tr");
for (const label of ["운반수단", "지반유형", "토량(㎥)", "평균운반거리(m)", "근거 구간", "비고"]) {
const th = document.createElement("th");
th.textContent = label;
headRow.append(th);
}
head.append(headRow);
const body = document.createElement("tbody");
for (const row of table.rows) {
const tr = document.createElement("tr");
tr.append(textCell(HAUL_LABELS[row.equipment] ?? row.equipment, "b08-grid__station"));
tr.append(textCell(row.ground));
tr.append(textCell(num(row.volume_m3, 2)));
tr.append(textCell(num(row.average_distance_m, 2)));
tr.append(textCell(String(row.legs)));
const note = textCell("", "b08-grid__note");
if (!row.in_bill) {
const tag = document.createElement("span");
tag.className = "b08-grid__tag";
tag.textContent = L("B08_Quantity_Haul_Excluded");
note.append(tag);
// 왜 빠지는지 같이 적는다 — 「제외」만 있으면 빠뜨린 것으로 오해된다.
note.append(document.createTextNode(" 품셈 1-2-7 소운반 20m 이내는 품에 포함"));
}
tr.append(note);
body.append(tr);
}
element.append(head, body);
scroller.append(element);
wrap.append(scroller);
return wrap;
}
+158
View File
@@ -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
+17
View File
@@ -619,6 +619,23 @@ export const ui_locales_b2 = {
"토적표를 불러오지 못했습니다.", "토적표를 불러오지 못했습니다.",
"Failed to load the earthwork table.", "Failed to load the earthwork table.",
], ],
B08_Quantity_Tab_Summary: ["토공집계", "Earthwork Summary"],
B08_Quantity_Tab_Haul: ["운반거리", "Haul Distance"],
B08_Quantity_Haul_Missing: [
"운반계획이 아직 없습니다. 종단설계에서 [확정]을 누르면 만들어집니다.",
"No haul plan yet. Press [Confirm] on the profile design to build it.",
],
B08_Quantity_Haul_Excluded: ["내역 제외", "Not billed"],
B08_Quantity_Side_Ratios: ["반영률(%)", "Application ratios (%)"],
B08_Quantity_Side_RockSet: ["암 갈래 세트", "Rock class set"],
B08_Quantity_Side_RockRatios: ["지반 구성비(%)", "Ground composition (%)"],
B08_Quantity_Btn_Save: ["저장", "Save"],
B08_Quantity_Save_Success: ["산출 조건을 저장했습니다.", "Calculation settings saved."],
B08_Quantity_Save_Failed: ["산출 조건을 저장하지 못했습니다.", "Failed to save the settings."],
B08_Quantity_Unsaved: [
"저장하지 않은 변경이 있습니다.",
"You have unsaved changes.",
],
B08_Quantity_Side_Method: ["산출법", "Method"], B08_Quantity_Side_Method: ["산출법", "Method"],
B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"], B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"],
B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"], B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"],