Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1
This commit is contained in:
@@ -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
|
||||
@@ -12,18 +12,35 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
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 (
|
||||
get_cross_section_designs,
|
||||
get_longitudinal_section,
|
||||
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_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
|
||||
|
||||
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")
|
||||
async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
||||
"""토적표 — 측점별 단면적을 평균단면적법으로 체적화한 표."""
|
||||
"""토적표 — 토공(체적)과 사면 4계열(면적)을 **한 응답**으로 낸다.
|
||||
|
||||
실무 토적표가 한 장이라 화면도 한 장이다. 나눠 부르면 두 번 왕복하고, 같은 측점 목록을
|
||||
두 벌로 들게 된다.
|
||||
"""
|
||||
try:
|
||||
designs = await run_with_connection(get_cross_section_designs, route_id)
|
||||
except Exception:
|
||||
@@ -48,10 +69,98 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
||||
content={"status": "error", "message": "토적표를 만들지 못했습니다."},
|
||||
)
|
||||
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
|
||||
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")
|
||||
async def get_earthwork_table_for_current_route(project_id: UUID) -> JSONResponse:
|
||||
"""경로를 안 주면 워크플로가 보고 있는 경로로 낸다 — 화면이 route_id 를 모를 때 쓴다."""
|
||||
|
||||
@@ -36,12 +36,45 @@ export interface EarthworkRow {
|
||||
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 {
|
||||
method: string;
|
||||
station_count: number;
|
||||
route_id?: number;
|
||||
rows: EarthworkRow[];
|
||||
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` 는 **표기 자리**이며 값 자체는 자르지 않는다. */
|
||||
@@ -119,12 +152,46 @@ const GROUPS: { label: string; sub: { label: string; cols: Column[] }[] }[] = [
|
||||
{ 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단 머리글 문구. */
|
||||
const TRIPLE_LABELS = ["단면적", "입 적", "보정량"];
|
||||
const PAIR_LABELS = ["단면적", "입 적"];
|
||||
|
||||
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`. 실무 토적표가 이 모양이다. */
|
||||
function stationLabel(chainage: number, interval = 20): string {
|
||||
const no = Math.floor(chainage / interval);
|
||||
@@ -178,13 +245,35 @@ function buildHead(): HTMLTableSectionElement {
|
||||
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);
|
||||
return head;
|
||||
}
|
||||
|
||||
function buildBody(rows: EarthworkRow[]): HTMLTableSectionElement {
|
||||
function buildBody(rows: EarthworkRow[], slope?: SlopeTable): HTMLTableSectionElement {
|
||||
const body = document.createElement("tbody");
|
||||
const columns = flatColumns();
|
||||
const slopeByChainage = new Map((slope?.rows ?? []).map((row) => [row.chainage_m, row]));
|
||||
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement("tr");
|
||||
columns.forEach((column, index) => {
|
||||
@@ -194,12 +283,25 @@ function buildBody(rows: EarthworkRow[]): HTMLTableSectionElement {
|
||||
if (index === 0) td.className = "b08-grid__station";
|
||||
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);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function buildFoot(totals: Record<string, number>): HTMLTableSectionElement {
|
||||
function buildFoot(totals: Record<string, number>, slope?: SlopeTable): HTMLTableSectionElement {
|
||||
const foot = document.createElement("tfoot");
|
||||
const tr = document.createElement("tr");
|
||||
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);
|
||||
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);
|
||||
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 {
|
||||
const wrap = document.createElement("div");
|
||||
@@ -226,7 +391,16 @@ export function renderEarthworkGrid(table: EarthworkTable): HTMLElement {
|
||||
scroller.className = "b08-grid__scroll";
|
||||
const element = document.createElement("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);
|
||||
wrap.append(scroller);
|
||||
return wrap;
|
||||
|
||||
@@ -63,6 +63,45 @@ const CSS = `
|
||||
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__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__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__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; }
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
import { renderEarthworkGrid, type EarthworkTable } from "./B08_Quantity_UI_EarthworkGrid";
|
||||
import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style";
|
||||
import { renderHaulGrid, renderSummaryGrid } from "./B08_Quantity_UI_SummaryGrid";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
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;
|
||||
}
|
||||
|
||||
/** [저장] — 산출 조건을 정본에 남긴다. `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 {
|
||||
const row = document.createElement("div");
|
||||
@@ -59,10 +78,43 @@ function field(label: string, value: string): HTMLElement {
|
||||
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(
|
||||
projectId: string | null,
|
||||
table: EarthworkTable | null,
|
||||
draft: DraftSettings,
|
||||
reload: () => void,
|
||||
): HTMLElement {
|
||||
const panel = document.createElement("div");
|
||||
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({
|
||||
label: L("B08_Quantity_Btn_Confirm"),
|
||||
variant: "filled",
|
||||
@@ -105,35 +208,67 @@ function buildQuantitySidePanel(
|
||||
return panel;
|
||||
}
|
||||
|
||||
/** 우측 본문 — 시트 탭 + 그 장의 표. 지금 서 있는 장은 토적표 하나다. */
|
||||
/** 우측 본문 — 시트 탭 + 고른 장의 표. 실무 산출서의 시트를 탭으로 옮긴 것이다. */
|
||||
function buildQuantityBody(table: EarthworkTable | null, failed: boolean): HTMLElement {
|
||||
const body = document.createElement("div");
|
||||
body.className = "b08-quantity__body";
|
||||
|
||||
const tabs = document.createElement("div");
|
||||
tabs.className = "b08-quantity__tabs";
|
||||
const tab = document.createElement("button");
|
||||
tab.type = "button";
|
||||
tab.className = "b08-quantity__tab is-active";
|
||||
tab.textContent = L("B08_Quantity_Tab_Earthwork");
|
||||
tabs.append(tab);
|
||||
body.append(tabs);
|
||||
const pane = document.createElement("div");
|
||||
pane.className = "b08-quantity__pane";
|
||||
|
||||
const message = (text: string): HTMLElement => {
|
||||
const element = document.createElement("p");
|
||||
element.className = "b08-quantity__message";
|
||||
element.textContent = text;
|
||||
return element;
|
||||
};
|
||||
|
||||
if (failed) {
|
||||
const message = document.createElement("p");
|
||||
message.className = "b08-quantity__message";
|
||||
message.textContent = L("B08_Quantity_Grid_Failed");
|
||||
body.append(message);
|
||||
body.append(tabs, message(L("B08_Quantity_Grid_Failed")));
|
||||
return body;
|
||||
}
|
||||
if (!table || !table.rows?.length) {
|
||||
const message = document.createElement("p");
|
||||
message.className = "b08-quantity__message";
|
||||
message.textContent = L("B08_Quantity_Grid_Empty");
|
||||
body.append(message);
|
||||
body.append(tabs, message(L("B08_Quantity_Grid_Empty")));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
if (projectId) {
|
||||
try {
|
||||
@@ -168,7 +323,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
|
||||
title: L("B08_Quantity_Title"),
|
||||
steps: workflowSteps(),
|
||||
activeStep: 5,
|
||||
leftPanel: buildQuantitySidePanel(projectId, table),
|
||||
leftPanel: buildQuantitySidePanel(projectId, table, draft, reload),
|
||||
mainContent: buildQuantityBody(table, failed),
|
||||
stages: workflowState?.stages,
|
||||
currentStage: workflowState?.current_stage,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -619,6 +619,23 @@ export const ui_locales_b2 = {
|
||||
"토적표를 불러오지 못했습니다.",
|
||||
"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_Value: ["평균단면적법", "Average end area"],
|
||||
B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"],
|
||||
|
||||
Reference in New Issue
Block a user