feat(b08): 연암 · 경암 = 암 × 비율(기본 90 : 10) · 토공집계표 네 줄 · 토적표 절토 연암 · 경암 두 열 · 거울 시험(122-3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
This commit is contained in:
2026-10-08 18:43:59 +09:00
co-authored by Claude Opus 5.5
parent a42fcd920e
commit 9a253ece97
8 changed files with 434 additions and 19 deletions
@@ -11,8 +11,11 @@ Q · L 출처 = 저장된 유토곡선 배분(`mass_haul.haul_plan` — B07 토
장비 띠(`blocks[].bands[]`)와 떨어진 구간 운반(`transfers[]`)의 `equipment` 로 가름
(`free_haul` = 무대 · `dozer` = 불도우저 · `dump_truck` = 덤프트럭).
암 갈래 — 리핑암 = 「연암」 줄 · 발파암 = 「경암」 줄. 풍화암 · 보통암 · 구조물 쪽 줄(su2 시트)은
값이 없어 비움(None).
암 갈래 — 「연암」 · 「경암」 줄 = 암 전체 × 비율(좌측 패널 · 기본 90 : 10 · PLAN 122).
풍화암 · 보통암 · 구조물 쪽 줄(su2 시트)은 값이 없어 비움(None).
짝 — `rock_split` · `split_rock_columns` ↔ `B08_Quantity_Options_Calc.ts` `rockSplit` ·
`splitRockColumns` · `splitRockSummary`(거울 시험 `test_122_b08_options_mirror`).
"""
from __future__ import annotations
@@ -20,8 +23,16 @@ from __future__ import annotations
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
from common_util.common_util_project_settings import SOFT_ROCK_PCT_DEFAULT
#: 운반 시트 열 — 장비 키
HAUL_EQUIPMENT = ("dump_truck", "dozer", "free_haul")
#: 암 칸 → 연암 칸 · 경암 칸(토적표 · 합계) · 칸 꼬리 셋
ROCK_SPLIT = (
("cut_rock", "cut_soft_rock", "cut_hard_rock"),
("ditch_rock", "ditch_soft_rock", "ditch_hard_rock"),
)
ROCK_SUFFIXES = ("area_m2", "volume_m3", "adjusted_m3")
def excel_round(value: float, digits: int = 0) -> float:
@@ -30,6 +41,24 @@ def excel_round(value: float, digits: int = 0) -> float:
return float(Decimal(str(value)).quantize(step, rounding=ROUND_HALF_UP))
def rock_split(value: float, soft_pct: float) -> tuple[float, float]:
"""암 → (연암, 경암) — 연암 = 암 × 비율(0~100 자름) · 경암 = 암 − 연암."""
soft = value * min(max(soft_pct, 0.0), 100.0) / 100.0
return soft, value - soft
def split_rock_columns(record: dict[str, Any], soft_pct: float) -> dict[str, Any]:
"""줄 · 합계의 암 칸마다 연암 · 경암 칸을 더함(숫자인 칸만)."""
for base, soft_key, hard_key in ROCK_SPLIT:
for suffix in ROCK_SUFFIXES:
value = record.get(f"{base}_{suffix}")
if isinstance(value, (int, float)) and not isinstance(value, bool):
soft, hard = rock_split(float(value), soft_pct)
record[f"{soft_key}_{suffix}"] = soft
record[f"{hard_key}_{suffix}"] = hard
return record
def haul_lists(haul_plan: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
"""장비별 `{rows: [{q, l, ql}], q, ql, average_m}` — 시트 한 줄 = 띠 · 운반 하나."""
items: dict[str, list[dict[str, float]]] = {key: [] for key in HAUL_EQUIPMENT}
@@ -58,9 +87,15 @@ def _row(group: str, kind: str, spec: str, unit: str, value: float | None, note:
return {"group": group, "kind": kind, "spec": spec, "unit": unit, "value": value, "note": note}
def summary_rows(totals: dict[str, float], haul: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
"""토공집계표 줄 — 시트 C5:G37 차례 · 식 그대로(셀 주소는 줄 끝 주석)."""
def summary_rows(
totals: dict[str, float],
haul: dict[str, dict[str, Any]],
soft_pct: float = SOFT_ROCK_PCT_DEFAULT,
) -> list[dict[str, Any]]:
"""토공집계표 줄 — 시트 C5:G37 차례 · 식 그대로(셀 주소는 줄 끝 주석) · 암 = 연암 % 로 나눔."""
t = totals
cut_soft, cut_hard = rock_split(t["cut_rock_volume_m3"], soft_pct)
ditch_soft, ditch_hard = rock_split(t["ditch_rock_volume_m3"], soft_pct)
r0 = excel_round
free_q, dump_q, dozer_q = haul["free_haul"]["q"], haul["dump_truck"]["q"], haul["dozer"]["q"]
diverted = t["diverted_m3"]
@@ -69,14 +104,14 @@ def summary_rows(totals: dict[str, float], haul: dict[str, dict[str, Any]]) -> l
return [
_row("흙깍기", "토사", "기계(굴삭기)", m3, r0(t["cut_soil_volume_m3"])), # 토적표!E137
_row("흙깍기", "풍화암", "굴삭기 + 브레카", m3, None),
_row("흙깍기", "연암", "굴삭기 + 브레카", m3, r0(t["cut_ripping_rock_volume_m3"])),
_row("흙깍기", "연암", "굴삭기 + 브레카", m3, r0(cut_soft)),
_row("흙깍기", "보통암", "굴삭기 + 브레카", m3, None),
_row("흙깍기", "경암", "발파 + 굴삭기", m3, r0(t["cut_blasting_rock_volume_m3"])),
_row("흙깍기", "경암", "발파 + 굴삭기", m3, r0(cut_hard)),
_row("측구 터파기", "토사", "기계", m3, r0(t["ditch_soil_volume_m3"])), # 토적표!K137
_row("측구 터파기", "풍화암", "굴삭기 + 브레카", m3, None),
_row("측구 터파기", "연암", "굴삭기 + 브레카", m3, r0(t["ditch_ripping_rock_volume_m3"])),
_row("측구 터파기", "연암", "굴삭기 + 브레카", m3, r0(ditch_soft)),
_row("측구 터파기", "보통암", "굴삭기 + 브레카", m3, None),
_row("측구 터파기", "경암", "발파 + 굴삭기", m3, r0(t["ditch_blasting_rock_volume_m3"])),
_row("측구 터파기", "경암", "발파 + 굴삭기", m3, r0(ditch_hard)),
_row("보정량계", "", "", m3, r0(t["adjusted_total_m3"])), # 토적표!P137
_row("성토", "", "", m3, r0(t["fill_volume_m3"])), # 토적표!R137
_row("유용토 소계", "", "", m3, diverted + free_q), # G18+G19
+18 -5
View File
@@ -7,6 +7,7 @@
단면적 = B06 횡단 설계 저장값(`cross_sections.data.design`) · 노선 = 확정 노선(없으면 최신 노선) ·
토량환산계수 = 유토곡선과 같은 `mixed_conversion_factors(quantity_settings)`.
암 → 연암 · 경암 = 암 × 연암 %(`rock_ratios_pct` · 기본 90 · PLAN 122) — 토적표 줄 · 합계 · 토공집계표.
"""
from __future__ import annotations
@@ -24,9 +25,17 @@ from B06_Section.B06_Section_Repository import (
get_workflow_route_context,
)
from B08_Quantity.B08_Quantity_Engine_Earthwork import StationArea, build_table
from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import haul_lists, summary_rows
from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import (
haul_lists,
split_rock_columns,
summary_rows,
)
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
from common_util.common_util_project_settings import mixed_conversion_factors, quantity_settings
from common_util.common_util_project_settings import (
mixed_conversion_factors,
quantity_settings,
soft_rock_pct,
)
from config.config_db import get_db_pool
router = APIRouter(prefix="/api/projects", tags=["B08 Earthwork"])
@@ -44,14 +53,18 @@ async def earthwork_table(project_id: UUID) -> dict[str, Any]:
route_id = int(context["route_id"])
rows = await get_cross_section_designs(connection, route_id)
section = await get_longitudinal_section(connection, project_id, route_id)
factors = await asyncio.to_thread(lambda: mixed_conversion_factors(quantity_settings(root)))
settings = await asyncio.to_thread(quantity_settings, root)
soft = soft_rock_pct(settings)
stations = [StationArea.from_design(row["chainage_m"], row["design"]) for row in rows]
table = build_table(stations, factors)
table = build_table(stations, mixed_conversion_factors(settings))
for record in [*table["rows"], table["totals"]]:
split_rock_columns(record, soft)
mass_haul = ((section or {}).get("data") or {}).get("mass_haul") or {}
haul = haul_lists(mass_haul.get("haul_plan"))
return {
**table,
"summary": summary_rows(table["totals"], haul),
"summary": summary_rows(table["totals"], haul, soft),
"rock_ratios_pct": {"연암": soft, "경암": 100.0 - soft},
"haul": haul,
"haul_saved": bool(mass_haul.get("haul_plan")),
"route_id": route_id,
+23 -1
View File
@@ -7,6 +7,7 @@
* 보정량 · 보정량계 · 성토 · 유용토 · 차인토량 · 누가토량 · 면고르기 · 지장목제거 · 제근 · 노면정리 각
* 거리 · 면적) · 맨 아래 합계 줄. 첫 측점은 앞이 없어 입적 · 면적이 빈 값(—).
* 표 = 공용 많은 자료 표(`ui_template_data_table` · PLAN 70) · 카드 안 · 나머지 셋은 `…_Earthwork_Sheets`.
* 절토 연암 · 경암 = 암 × 좌측 패널 비율 — 조작 중 같은 식으로 바로 다시 셈(`…_Options_Calc` · PLAN 122).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
@@ -19,6 +20,8 @@ import {
renderFreeHaul,
renderHaulDistance,
} from "./B08_Quantity_UI_Earthwork_Sheets";
import { splitRockColumns, splitRockSummary } from "./B08_Quantity_Options_Calc";
import { liveSoftRock, onOptionsChange } from "./B08_Quantity_UI_Options";
type Face = { length_m: number | null; area_m2: number | null };
@@ -80,12 +83,15 @@ export interface EarthworkTable {
summary: SummaryRow[];
haul: Record<"dump_truck" | "dozer" | "free_haul", HaulList>;
haul_saved: boolean;
/** 저장된 연암 · 경암 %(암 전체 기준) */
rock_ratios_pct: { 연암: number; 경암: number };
}
/** 칸 묶음 — [머리 묶음, 키 앞머리, 풍선 글] · 단면적 · 입적 · 보정량 셋 · 머리 글자는 사용자 견본대로 */
const TRIPLES: Array<[string[], string, string?]> = [
[["절토", "토사"], "cut_soil"],
[["절토", "연암"], "cut_rock"],
[["절토", "연암"], "cut_soft_rock", "암 × 연암 %"],
[["절토", "경암"], "cut_hard_rock", "암 × 경암 %"],
[["측구파기", "토사"], "ditch_soil"],
[["측구파기", "암"], "ditch_rock", "리핑암 · 발파암 합"],
];
@@ -254,11 +260,23 @@ export async function renderEarthwork(host: HTMLElement, projectId: string): Pro
return;
}
// 연암 · 경암 칸 · 집계표 줄 = 암 × 지금 비율(캐시 → 저장본)
const summary = table.summary;
const applyRock = (): void => {
const soft = liveSoftRock(projectId, table.rock_ratios_pct?.연암 ?? 90);
for (const record of [...table.rows, table.totals])
splitRockColumns(record as unknown as Record<string, unknown>, soft);
table.summary = splitRockSummary(summary, table.totals, soft);
};
applyRock();
const tabs = document.createElement("div");
tabs.className = "b08-quantity__tabs b08-earthwork__tabs";
tabs.setAttribute("role", "tablist");
const view = document.createElement("div");
let current: SheetId = "summary";
const select = (picked: SheetId): void => {
current = picked;
tabs.replaceChildren(
...SHEETS.map(({ id, label }) => {
const button = createButton({
@@ -280,4 +298,8 @@ export async function renderEarthwork(host: HTMLElement, projectId: string): Pro
};
host.replaceChildren(tabs, view);
select("summary");
onOptionsChange(host, () => {
applyRock();
select(current);
});
}
@@ -28,7 +28,7 @@ const SUMMARY_COLUMNS: DataColumn[] = [
export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void {
const info = note(
"토적표 합계 · 평균운반거리 · 종방향 무대운반거리를 실무 토공집계표 식대로 묶음 · " +
"리핑암 = 연암 · 발파암 = 경암 · 구조물 쪽 줄은 구조물 수량에서(- = 아직 값 없음)",
"연암 · 경암 = 암 × 좌측 패널 비율 · 구조물 쪽 줄은 구조물 수량에서(- = 아직 값 없음)",
);
const grid = createDataTable({
columns: SUMMARY_COLUMNS,
+164
View File
@@ -0,0 +1,164 @@
# -*- coding: utf-8 -*-
"""122 B08 좌측 패널 옵션 — 배관 1본 길이 · 연암 / 경암 비율 · 저장 길 · 화면 배선.
1본 길이 정본 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m` · 비율 정본 =
`project_settings.json › quantity › rock_ratios_pct`(기본 90 : 10 은 파일에 안 씀).
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
from B08_Quantity import B08_Quantity_Router_Options as options
from B08_Quantity import B08_Quantity_Router_Summary as summary
from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import (
excel_round,
haul_lists,
split_rock_columns,
summary_rows,
)
from common_util.common_util_project_settings import settings_path, soft_rock_pct
from M02_MasterTemplete import M02_Template_Layers as layers
ROOT = Path(__file__).resolve().parents[2]
@pytest.fixture
def root(tmp_path, monkeypatch) -> Path:
system = tmp_path / "master"
(system / "table").mkdir(parents=True)
(system / "table/구조물집계표.json").write_text(
json.dumps({"열": [], "변수": {"파형강관_1본_m": 8, "측점간격_m": 20}}), encoding="utf-8"
)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
project = tmp_path / "project"
project.mkdir()
return project
def test_기본값은_8_m_와_90_10_파일에_안_씀(root):
assert options.read_options(root) == {
"one_pipe_length_m": 8,
"rock_ratios_pct": {"연암": 90.0, "경암": 10.0},
}
assert not settings_path(root).exists()
assert not layers.template_path(layers.project_dir(root), "table", "구조물집계표").exists()
def test_1본_6_m_저장_뒤_커플링을_다시_셈(root):
assert summary.couplings(22, summary.one_pipe_length(root)) == 2 # 올림(22/8) − 1
out = options.save_options(root, options.OptionsSave(one_pipe_length_m=6))
assert out["one_pipe_length_m"] == 6 == summary.one_pipe_length(root)
assert summary.couplings(22, 6) == 3 and summary.couplings(16, 6) == 2
# 프로젝트 층 사본 = 시스템 문서 그대로 + 변수 한 칸 · 시스템 층은 안 바뀜
mine = layers.read_template(layers.project_dir(root), "table", "구조물집계표")["문서"]
assert mine["변수"] == {"파형강관_1본_m": 6, "측점간격_m": 20} and mine["열"] == []
system = layers.read_template(layers.system_dir(), "table", "구조물집계표")["문서"]
assert system["변수"]["파형강관_1본_m"] == 8
# 다시 저장 = 같은 사본 판 위에 씀
options.save_options(root, options.OptionsSave(one_pipe_length_m=8))
assert summary.one_pipe_length(root) == 8
def test_비율_70_30_저장_뒤_남고_다른_칸은_그대로(root):
settings_path(root).write_text(
json.dumps(
{"quantity": {"rock_ratios_pct": {"풍화암": 5}, "rock_methods": {"연암": "ripping"}}}
),
encoding="utf-8",
)
out = options.save_options(root, options.OptionsSave(soft_rock_pct=70))
assert out["rock_ratios_pct"] == {"연암": 70.0, "경암": 30.0}
stored = json.loads(settings_path(root).read_text(encoding="utf-8"))["quantity"]
assert stored["rock_ratios_pct"] == {"풍화암": 5, "연암": 70.0, "경암": 30.0}
assert stored["rock_methods"] == {"연암": "ripping"}
assert options.read_options(root)["rock_ratios_pct"]["연암"] == 70.0
# 1본 길이는 안 보냈으니 그대로
assert options.read_options(root)["one_pipe_length_m"] == 8
def test_비율_읽기_자름_경암만_있을_때():
assert soft_rock_pct({}) == 90
assert soft_rock_pct({"rock_ratios_pct": {"연암": 130}}) == 100
assert soft_rock_pct({"rock_ratios_pct": {"경암": 25}}) == 75
assert soft_rock_pct({"rock_ratios_pct": {"연암": True}}) == 90
def test_범위_밖_값은_거절():
with pytest.raises(ValueError):
options.OptionsSave(one_pipe_length_m=0)
with pytest.raises(ValueError):
options.OptionsSave(soft_rock_pct=101)
def test_토공집계표_연암_경암은_암_곱하기_비율_합_100():
totals = {
"cut_soil_volume_m3": 10.0,
"cut_rock_volume_m3": 47.6,
"ditch_soil_volume_m3": 1.0,
"ditch_rock_volume_m3": 2.3,
"adjusted_total_m3": 60.0,
"fill_volume_m3": 5.0,
"diverted_m3": 5.0,
"balance_m3": 50.0,
"benching_area_m2": 0.0,
"grading_fill_area_m2": 0.0,
"grading_cut_area_m2": 0.0,
"tree_removal_fill_area_m2": 0.0,
"tree_removal_cut_area_m2": 0.0,
}
rows = summary_rows(totals, haul_lists(None), 70)
value = {(r["group"], r["kind"]): r["value"] for r in rows}
assert value[("흙깍기", "연암")] == excel_round(47.6 * 0.7) == 33
assert value[("흙깍기", "경암")] == excel_round(47.6 - 47.6 * 0.7) == 14
assert value[("측구 터파기", "연암")] == 2 and value[("측구 터파기", "경암")] == 1
# 토적표 칸 · 합계 — 연암 + 경암 = 암
record = split_rock_columns({"cut_rock_area_m2": 1.2, "cut_rock_volume_m3": 47.6}, 70)
assert record["cut_soft_rock_area_m2"] == pytest.approx(0.84)
assert record["cut_soft_rock_volume_m3"] + record["cut_hard_rock_volume_m3"] == pytest.approx(
47.6
)
def test_라우터_등록_응답_칸():
main = (ROOT / "main.py").read_text(encoding="utf-8")
assert "app.include_router(b08_options_router, dependencies=protected_with_company)" in main
earthwork = (ROOT / "B08_Quantity/B08_Quantity_Router_Earthwork.py").read_text(encoding="utf-8")
assert "split_rock_columns(record, soft)" in earthwork and '"rock_ratios_pct"' in earthwork
pipe = (ROOT / "B08_Quantity/B08_Quantity_Router_Summary.py").read_text(encoding="utf-8")
assert '"pipes": pipes' in pipe
def test_좌측_패널_컨테이너_셋과_저장_줄():
page = (ROOT / "B08_Quantity/B08_Quantity_UI_Page.ts").read_text(encoding="utf-8")
panel = (ROOT / "B08_Quantity/B08_Quantity_UI_Options.ts").read_text(encoding="utf-8")
assert "leftPanel: createOptionsPanel(projectId, () => select(current))" in page
for tab in ("structure", "earthwork", "hydrology"):
assert f'card("{tab}", "B08_Quantity_Tab_' in panel, tab
assert "ui-collapsible ui-sidebar-section ui-sidebar-card" in panel
assert (
'actions.className = "ui-sidebar-actions"' in panel and "attachCollapsible(panel)" in panel
)
assert len(re.findall(r'type: "number",\s+compact: true', panel)) == 3
# 조작 = 캐시 · [저장] = PUT 하나 · 자동 저장 없음
assert 'writeState("b08-options"' in panel and 'clearState("b08-options"' in panel
assert panel.count('method: "PUT"') == 1
registry = (ROOT / "A00_Common/b_page_state.ts").read_text(encoding="utf-8")
assert '"b08-options": { bucket: "draft", scope: "project" }' in registry
def test_표가_옵션_바뀜에_바로_다시_셈():
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
assert "recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m))" in ui
assert "onOptionsChange(host," in ui
earth = (ROOT / "B08_Quantity/B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8")
assert "splitRockSummary(summary, table.totals, soft)" in earth
assert "onOptionsChange(host," in earth
assert (
'[["절토", "연암"], "cut_soft_rock"' in earth
and '[["절토", "경암"], "cut_hard_rock"' in earth
)
@@ -0,0 +1,177 @@
# -*- coding: utf-8 -*-
"""122 거울 — 좌측 패널 옵션 식 · 파이썬 ↔ TS(`B08_Quantity_Options_Calc.ts`).
조작 중 브라우저가 세는 값 = [저장] 뒤 서버가 세는 값이어야 함:
커플링(관마다 올림(L ÷ 1본) − 1 · 같은 측점 더함) · 암 → 연암 · 경암 칸 · 토공집계표 네 줄.
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import pytest
from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import (
haul_lists,
rock_split,
split_rock_columns,
summary_rows,
)
from B08_Quantity.B08_Quantity_Router_Summary import couplings
ROOT = Path(__file__).resolve().parents[2]
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
LENGTHS = [None, 0, 5, 8, 8.0000000001, 16, 17, 22, 23.5, 48]
ONES = [None, 6, 8, 2.5]
PCTS = [0, 10, 33.3, 70, 90, 100, 130, -5]
ROCKS = [0.0, 1.0, 2.3, 47.6, 123.456]
PIPES = [
{"chainage_m": 1433.0, "column": "pipe:cp|1200", "length_m": 22},
{"chainage_m": 1567.0, "column": "pipe:cp|800", "length_m": 18},
{"chainage_m": 1567.0, "column": "pipe:cp|800", "length_m": 9}, # 같은 측점 둘째 관
{"chainage_m": 1680.0, "column": "pipe:cp|800", "length_m": 16},
{"chainage_m": 1790.0, "column": "pipe:cp|800", "length_m": None},
]
SUMMARY = {
"rows": [
{"chainage_m": 1433.0, "values": {"pipe:len|1200": 22, "pipe:cp|1200": 99}},
{"chainage_m": 1567.0, "values": {"pipe:len|800": 27, "pipe:cp|800": 99}},
{"chainage_m": 1680.0, "values": {"pipe:len|800": 16, "pipe:cp|800": 99}},
{"chainage_m": 1790.0, "values": {}},
],
"total": {"pipe:len|1200": 22, "pipe:len|800": 43, "pipe:cp|1200": 99, "pipe:cp|800": 99},
"pipes": PIPES,
}
TOTALS = {
"cut_soil_volume_m3": 10.0,
"cut_rock_volume_m3": 47.6,
"ditch_soil_volume_m3": 1.0,
"ditch_rock_volume_m3": 2.3,
"adjusted_total_m3": 60.0,
"fill_volume_m3": 5.0,
"diverted_m3": 5.0,
"balance_m3": 50.0,
"benching_area_m2": 0.0,
"grading_fill_area_m2": 0.0,
"grading_cut_area_m2": 0.0,
"tree_removal_fill_area_m2": 0.0,
"tree_removal_cut_area_m2": 0.0,
}
RECORD = {
"cut_rock_area_m2": 1.2,
"cut_rock_volume_m3": 47.6,
"cut_rock_adjusted_m3": 54.74,
"ditch_rock_area_m2": 0.18,
"ditch_rock_volume_m3": 2.3,
"ditch_rock_adjusted_m3": None,
}
_RUNNER = """
import { writeFileSync } from "node:fs";
import * as calc from "./B08_Quantity_Options_Calc.js";
const input = JSON.parse(process.argv[3]);
const out = {
couplings: input.lengths.map((l) => input.ones.map((o) => calc.couplings(l, o))),
splits: input.rocks.map((r) => input.pcts.map((p) => calc.rockSplit(r, p))),
records: input.pcts.map((p) => { const r = { ...input.record }; calc.splitRockColumns(r, p); return r; }),
summary: input.pcts.map((p) => calc.splitRockSummary(input.rows, input.totals, p)),
recount: input.ones.map((o) => calc.recountCouplings(input.summary, o)),
};
writeFileSync(process.argv[2], JSON.stringify(out));
"""
def _ts(tmp_path: Path, payload: dict) -> dict:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(ROOT / "B08_Quantity" / "B08_Quantity_Options_Calc.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
result = tmp_path / "out.json"
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(result), json.dumps(payload)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def _py_recount(one: float | None) -> dict:
"""서버 `_Sheet` 와 같은 모음 — 관마다 couplings · 같은 측점(1 cm) 더함 · 계 = 줄 합."""
rows = []
for row in SUMMARY["rows"]:
values = {k: v for k, v in row["values"].items() if not k.startswith("pipe:cp|")}
for pipe in PIPES:
count = couplings(pipe["length_m"], one)
if count is None or round(pipe["chainage_m"] * 100) != round(row["chainage_m"] * 100):
continue
values[pipe["column"]] = values.get(pipe["column"], 0) + count
rows.append({"chainage_m": row["chainage_m"], "values": values})
total = {k: v for k, v in SUMMARY["total"].items() if not k.startswith("pipe:cp|")}
for row in rows:
for key, value in row["values"].items():
if key.startswith("pipe:cp|"):
total[key] = total.get(key, 0) + value
return {**SUMMARY, "rows": rows, "total": total}
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_두_쪽이_같은_값을_낸다(tmp_path: Path) -> None:
rows = summary_rows(TOTALS, haul_lists(None), 90)
ts = _ts(
tmp_path,
{
"lengths": LENGTHS,
"ones": ONES,
"rocks": ROCKS,
"pcts": PCTS,
"record": RECORD,
"rows": rows,
"totals": TOTALS,
"summary": SUMMARY,
},
)
assert ts["couplings"] == [[couplings(l, o) for o in ONES] for l in LENGTHS]
assert ts["splits"] == [[list(rock_split(r, p)) for p in PCTS] for r in ROCKS]
for pct, got in zip(PCTS, ts["records"]):
assert got == split_rock_columns(dict(RECORD), pct), pct
for pct, got in zip(PCTS, ts["summary"]):
assert got == summary_rows(TOTALS, haul_lists(None), pct), pct
for one, got in zip(ONES, ts["recount"]):
assert got == _py_recount(one), one
def test_1본_6_m_면_커플링이_바뀜(tmp_path: Path) -> None:
six, eight = _py_recount(6), _py_recount(8)
assert six["total"]["pipe:cp|800"] == 2 + 1 + 2 and eight["total"]["pipe:cp|800"] == 2 + 1 + 1
assert six["total"]["pipe:cp|1200"] == 3 and eight["total"]["pipe:cp|1200"] == 2
def test_식_모양이_두_쪽_같다() -> None:
ts = (ROOT / "B08_Quantity" / "B08_Quantity_Options_Calc.ts").read_text(encoding="utf-8")
py = (ROOT / "B08_Quantity" / "B08_Quantity_Engine_Earthwork_Summary.py").read_text("utf-8")
assert "Math.max(Math.ceil(lengthM / oneM - 1e-9) - 1, 0)" in ts
assert "const soft = (value * Math.min(Math.max(softPct, 0), 100)) / 100;" in ts
assert "soft = value * min(max(soft_pct, 0.0), 100.0) / 100.0" in py
assert not re.search(r"^import ", ts, re.M) # 시험이 이 파일만 옮겨 돌림
@@ -182,9 +182,12 @@ def test_토공집계표_식() -> None:
rows = summary_rows(t, haul_lists(HAUL_PLAN))
value = {(r["group"], r["kind"], r["spec"]): r["value"] for r in rows}
assert value[("흙깍기", "토사", "기계(굴삭기)")] == excel_round(t["cut_soil_volume_m3"])
assert value[("흙깍기", "연암", "굴삭기 + 브레카")] == 48
assert value[("흙깍기", "경암", "발파 + 굴삭기")] == 0
assert value[("측구 터파기", "연암", "굴삭기 + 브레카")] == 2
# 연암 · 경암 = 암 × 기본 90 : 10(122)
rock, ditch = t["cut_rock_volume_m3"], t["ditch_rock_volume_m3"]
assert value[("흙깍기", "연암", "굴삭기 + 브레카")] == excel_round(rock * 0.9) == 43
assert value[("흙깍기", "경암", "발파 + 굴삭기")] == excel_round(rock - rock * 0.9) == 5
assert value[("측구 터파기", "연암", "굴삭기 + 브레카")] == excel_round(ditch * 0.9)
assert value[("측구 터파기", "경암", "발파 + 굴삭기")] == excel_round(ditch - ditch * 0.9)
assert value[("보정량계", "", "")] == 91
assert value[("성토", "", "")] == 189
assert value[("종방향유용토", "", "백호우")] == 82
@@ -205,6 +208,7 @@ def test_토적표_머리_글자는_사용자_견본대로():
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8")
for head in (
'["절토", "연암"]',
'["절토", "경암"]',
'["측구파기", "토사"]',
'["측구파기", "암"]',
"리핑암 · 발파암 합",
@@ -63,4 +63,4 @@ def test_토적표_유토곡선_운반표가_같은_함수를_씀() -> None:
router = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Earthwork.py").read_text("utf-8")
assert prebuild.count("mixed_conversion_factors(") == 2
assert "earthwork_conversion_factors(" not in prebuild
assert router.count("mixed_conversion_factors(settings)") == 2
assert router.count("mixed_conversion_factors(settings)") == 1