Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1
# Conflicts: # B08_Quantity/B08_Quantity_Router_Summary.py # B08_Quantity/B08_Quantity_UI_Page.css # B08_Quantity/B08_Quantity_UI_Summary.ts # main.py
This commit is contained in:
@@ -168,6 +168,8 @@ export const STATE_REGISTRY = {
|
||||
"b08-materials": { bucket: "draft", scope: "project" },
|
||||
/** B08 홍수량 계산서 입력 `{측점: 입력}` — [저장] 때 B08 자기 파일로 · B05 연결값과 같은 칸은 null(연동 · 96). */
|
||||
"b08-hydro": { bucket: "draft", scope: "project", version: 3 },
|
||||
/** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */
|
||||
"b08-options": { bucket: "draft", scope: "project" },
|
||||
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
|
||||
* 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */
|
||||
"alignment-draft": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_Options_Calc.ts
|
||||
* 좌측 패널 옵션 — 조작 중 브라우저가 바로 다시 세는 식(화면 없음 · PLAN 122).
|
||||
*
|
||||
* 짝(파이썬 · 같은 식) — 거울 시험 `test_122_b08_options_mirror`:
|
||||
* couplings · recountCouplings ↔ `B08_Quantity_Router_Summary.py` couplings(관마다 `pipes`)
|
||||
* rockSplit · splitRockColumns · splitRockSummary
|
||||
* ↔ `B08_Quantity_Engine_Earthwork_Summary.py` rock_split ·
|
||||
* split_rock_columns · summary_rows(연암 · 경암 네 줄)
|
||||
* [저장] 뒤에는 서버가 정본으로 다시 셈 — 여기 값을 받아 적지 않음.
|
||||
* import 없음 — 시험이 이 파일만 옮겨 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 커플링 = 올림(L ÷ 1본) − 1(0 아래 없음) · 길이나 1본이 빈칸이면 null. */
|
||||
export function couplings(lengthM: number | null | undefined, oneM: number | null): number | null {
|
||||
if (!lengthM || !oneM) return null;
|
||||
return Math.max(Math.ceil(lengthM / oneM - 1e-9) - 1, 0);
|
||||
}
|
||||
|
||||
export interface PipeLength {
|
||||
chainage_m: number;
|
||||
/** 커플링 열 id(`pipe:cp|관경`) */
|
||||
column: string;
|
||||
length_m: number | null;
|
||||
}
|
||||
|
||||
interface CouplingSummary {
|
||||
rows: Array<{ chainage_m: number; values: Record<string, number> }>;
|
||||
total: Record<string, number>;
|
||||
pipes: PipeLength[];
|
||||
}
|
||||
|
||||
/** 커플링 열만 새 1본 길이로 — 같은 측점(1 cm)의 같은 열은 더함 · 계 = 줄 합. */
|
||||
export function recountCouplings<S extends CouplingSummary>(summary: S, oneM: number | null): S {
|
||||
const at = (chainage: number): number => Math.round(chainage * 100);
|
||||
const columns = new Set(summary.pipes.map((pipe) => pipe.column));
|
||||
const sums = new Map<string, number>();
|
||||
for (const pipe of summary.pipes) {
|
||||
const count = couplings(pipe.length_m, oneM);
|
||||
if (count === null) continue;
|
||||
const key = `${at(pipe.chainage_m)}|${pipe.column}`;
|
||||
sums.set(key, (sums.get(key) ?? 0) + count);
|
||||
}
|
||||
const keep = (values: Record<string, number>): Record<string, number> =>
|
||||
Object.fromEntries(Object.entries(values).filter(([id]) => !columns.has(id)));
|
||||
const rows = summary.rows.map((row) => {
|
||||
const values = keep(row.values);
|
||||
for (const column of columns) {
|
||||
const count = sums.get(`${at(row.chainage_m)}|${column}`);
|
||||
if (count !== undefined) values[column] = count;
|
||||
}
|
||||
return { ...row, values };
|
||||
});
|
||||
const total = keep(summary.total);
|
||||
for (const row of rows)
|
||||
for (const column of columns)
|
||||
if (row.values[column] !== undefined)
|
||||
total[column] = (total[column] ?? 0) + row.values[column];
|
||||
return { ...summary, rows, total };
|
||||
}
|
||||
|
||||
/** 0~100 으로 자름 · 숫자가 아니면 null. */
|
||||
export function clampPct(value: number): number | null {
|
||||
return Number.isFinite(value) ? Math.min(Math.max(value, 0), 100) : null;
|
||||
}
|
||||
|
||||
/** 암 → [연암, 경암] — 연암 = 암 × 비율(0~100 자름) · 경암 = 암 − 연암. */
|
||||
export function rockSplit(value: number, softPct: number): [number, number] {
|
||||
const soft = (value * Math.min(Math.max(softPct, 0), 100)) / 100;
|
||||
return [soft, value - soft];
|
||||
}
|
||||
|
||||
const ROCK_SPLIT = [
|
||||
["cut_rock", "cut_soft_rock", "cut_hard_rock"],
|
||||
["ditch_rock", "ditch_soft_rock", "ditch_hard_rock"],
|
||||
] as const;
|
||||
const ROCK_SUFFIXES = ["area_m2", "volume_m3", "adjusted_m3"] as const;
|
||||
|
||||
/** 줄 · 합계의 암 칸마다 연암 · 경암 칸을 덮어씀(숫자인 칸만). */
|
||||
export function splitRockColumns(record: Record<string, unknown>, softPct: number): void {
|
||||
for (const [base, softKey, hardKey] of ROCK_SPLIT)
|
||||
for (const suffix of ROCK_SUFFIXES) {
|
||||
const value = record[`${base}_${suffix}`];
|
||||
if (typeof value !== "number") continue;
|
||||
const [soft, hard] = rockSplit(value, softPct);
|
||||
record[`${softKey}_${suffix}`] = soft;
|
||||
record[`${hardKey}_${suffix}`] = hard;
|
||||
}
|
||||
}
|
||||
|
||||
/** 토공집계표 연암 · 경암 줄 — 흙깍기 = 절토 암 · 측구 터파기 = 측구 암 합계 × 비율 · 반올림 정수. */
|
||||
const ROCK_ROWS: Record<string, string> = {
|
||||
흙깍기: "cut_rock_volume_m3",
|
||||
"측구 터파기": "ditch_rock_volume_m3",
|
||||
};
|
||||
|
||||
export function splitRockSummary<R extends { group: string; kind: string; value: number | null }>(
|
||||
rows: readonly R[],
|
||||
totals: Record<string, number>,
|
||||
softPct: number,
|
||||
): R[] {
|
||||
return rows.map((row) => {
|
||||
const base = ROCK_ROWS[row.group];
|
||||
if (!base || (row.kind !== "연암" && row.kind !== "경암")) return row;
|
||||
const [soft, hard] = rockSplit(totals[base] ?? 0, softPct);
|
||||
return { ...row, value: Math.round(row.kind === "연암" ? soft : hard) };
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""B08 좌측 패널 옵션 길(PLAN 122) — 배관 1본 길이 · 연암 / 경암 비율.
|
||||
|
||||
GET /api/projects/{id}/quantity/options 지금 값
|
||||
PUT /api/projects/{id}/quantity/options [저장] — 받은 칸만 정본 자리에 씀
|
||||
|
||||
1본 길이 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m` 한 벌(98 `one_pipe_length` 이 읽는 자리 ·
|
||||
M02 표 식 `[$파형강관_1본_m]` 도 같은 값) · 프로젝트 사본이 없으면 시스템 층 문서를 바탕으로 깔고 씀 ·
|
||||
M02 [초기화]면 같이 되돌아감.
|
||||
연암 % = `project_settings.json › quantity › rock_ratios_pct`(연암 · 경암 = 100 − 연암) · 기본 90 은
|
||||
파일에 안 쓰고 읽을 때 채움(`soft_rock_pct`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||||
from B08_Quantity.B08_Quantity_Router_Summary import ONE_LENGTH_VAR, TABLE_NAME, one_pipe_length
|
||||
from common_util.common_util_project_settings import quantity_settings, save_section, soft_rock_pct
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B08 Options"])
|
||||
|
||||
|
||||
class OptionsSave(BaseModel):
|
||||
"""빈칸(None) = 그 칸은 그대로."""
|
||||
|
||||
one_pipe_length_m: float | None = Field(default=None, gt=0, le=100)
|
||||
soft_rock_pct: float | None = Field(default=None, ge=0, le=100)
|
||||
|
||||
|
||||
def read_options(root: str | Path) -> dict[str, Any]:
|
||||
soft = soft_rock_pct(quantity_settings(root))
|
||||
return {
|
||||
"one_pipe_length_m": one_pipe_length(root),
|
||||
"rock_ratios_pct": {"연암": soft, "경암": 100.0 - soft},
|
||||
}
|
||||
|
||||
|
||||
def _write_one_pipe(root: str | Path, value: float) -> None:
|
||||
"""프로젝트 층 구조물집계표 변수 한 칸 — 판이 그새 바뀌었으면 `StaleTemplate`."""
|
||||
folder = layers.project_dir(root)
|
||||
found = layers.read_template(folder, "table", TABLE_NAME)
|
||||
version = found["판"] if found else None
|
||||
found = found or layers.read_template(layers.system_dir(), "table", TABLE_NAME)
|
||||
if found is None:
|
||||
raise HTTPException(status_code=404, detail=f"{TABLE_NAME} 양식이 없습니다 — M02 표부터.")
|
||||
document = found["문서"]
|
||||
document["변수"] = {**(document.get("변수") or {}), ONE_LENGTH_VAR: value}
|
||||
layers.write_template(
|
||||
folder, "table", TABLE_NAME, document, version=version, check_version=True
|
||||
)
|
||||
|
||||
|
||||
def save_options(root: str | Path, body: OptionsSave) -> dict[str, Any]:
|
||||
if body.one_pipe_length_m is not None:
|
||||
_write_one_pipe(root, body.one_pipe_length_m)
|
||||
if body.soft_rock_pct is not None:
|
||||
soft = float(body.soft_rock_pct)
|
||||
ratios = {
|
||||
**(quantity_settings(root).get("rock_ratios_pct") or {}),
|
||||
"연암": soft,
|
||||
"경암": 100.0 - soft,
|
||||
}
|
||||
save_section(
|
||||
root, "quantity", {"rock_ratios_pct": ratios}, replace_keys=("rock_ratios_pct",)
|
||||
)
|
||||
return read_options(root)
|
||||
|
||||
|
||||
@router.get("/{project_id}/quantity/options")
|
||||
async def options_read(project_id: UUID) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
return await asyncio.to_thread(read_options, root)
|
||||
|
||||
|
||||
@router.put("/{project_id}/quantity/options")
|
||||
async def options_save(project_id: UUID, body: OptionsSave) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
try:
|
||||
return await asyncio.to_thread(save_options, root, body)
|
||||
except layers.StaleTemplate as error:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="구조물집계표가 그새 바뀌었습니다 — 다시 열고 저장하세요."
|
||||
) from error
|
||||
@@ -21,6 +21,8 @@
|
||||
머리 글 = M02 정의 이름(`구조물정의.json` types.name · 종류.{종류}.이름 — 사용자가 M02 에서 고침) ·
|
||||
열마다 `combos` = 그 값을 낸 컨테이너에 이은 조합 id(`{층}|{주인}|{키}` · 95-3 모달 · 맨 윗 머리).
|
||||
설계 = B08 토공과 같은 길(확정 노선 먼저) · 관 · 컨테이너 줄 = M02 채움 `collect_items` 재사용.
|
||||
`pipes` = 관마다 길이(커플링 열 · 측점) — 좌측 패널 1본 길이를 바꾸면 화면이 같은 식으로 바로 다시 셈
|
||||
(짝 = `couplings` ↔ `B08_Quantity_Options_Calc.ts` `couplings` · `recountCouplings` · PLAN 122).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -456,6 +458,7 @@ def pipe_summary(
|
||||
entry["base"] = item
|
||||
|
||||
sheet = _Sheet()
|
||||
pipes: list[dict[str, Any]] = []
|
||||
no_length = no_basin = no_box = 0
|
||||
for (type_id, owner), entry in by_owner.items():
|
||||
base = entry.get("base")
|
||||
@@ -494,6 +497,7 @@ def pipe_summary(
|
||||
couplings(length, one_m),
|
||||
pipe_combos,
|
||||
)
|
||||
pipes.append({"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length})
|
||||
shape = fill._nearest(basins, at)
|
||||
no_basin += shape is None
|
||||
if shape:
|
||||
@@ -522,7 +526,13 @@ def pipe_summary(
|
||||
notes.append(f"BOX 본체 길이 없음 {no_box}곳 — B06 횡단 [저장] 뒤 채워짐")
|
||||
if one_m is None:
|
||||
notes.append(f"1본 길이 변수({ONE_LENGTH_VAR}) 빈칸 — 커플링 빈칸")
|
||||
return {**out, "notes": notes, "one_length_m": one_m, "types": list(DRAIN_TYPES)}
|
||||
return {
|
||||
**out,
|
||||
"notes": notes,
|
||||
"one_length_m": one_m,
|
||||
"types": list(DRAIN_TYPES),
|
||||
"pipes": pipes,
|
||||
}
|
||||
|
||||
|
||||
def _summary(root: Path, designs: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -44,7 +44,7 @@ interface HydroStation {
|
||||
type Drafts = Record<string, Partial<HydroInputs>>;
|
||||
type HydroPayload = { stations: HydroStation[]; saved: Drafts } & HydroOptions;
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
export async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Options.ts
|
||||
* 좌측 패널(PLAN 122) — 메인 탭과 같은 컨테이너 셋(구조물 · 토공 · 수리집수면적표 · 접이) + [저장] 줄.
|
||||
*
|
||||
* 구조물 › 배관 1본 길이(m) — 정본 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m`
|
||||
* 토공 › 연암 % · 경암 %(암 전체 기준 · 한쪽 고치면 다른 쪽 = 100 − 값 · 0~100)
|
||||
* 수리집수면적표 — 빈 자리(다음 옵션)
|
||||
* 조작 = 캐시 `b08-options` 즉시 + 열린 표가 같은 식으로 바로 다시 셈(`…_Options_Calc`) ·
|
||||
* [저장] = PUT …/quantity/options → 캐시 비움 · 열린 탭을 서버 정본으로 다시 그림 · 자동 저장 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { request } from "./B08_Quantity_UI_Hydro";
|
||||
import { clampPct } from "./B08_Quantity_Options_Calc";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** 조작 중 값 — 없는 칸은 저장본 그대로 · 1본 null = 빈칸. */
|
||||
export interface OptionsDraft {
|
||||
one_pipe_length_m?: number | null;
|
||||
soft_rock_pct?: number;
|
||||
}
|
||||
|
||||
interface SavedOptions {
|
||||
one_pipe_length_m: number | null;
|
||||
rock_ratios_pct: { 연암: number; 경암: number };
|
||||
}
|
||||
|
||||
const changes = new EventTarget();
|
||||
|
||||
const readDraft = (projectId: string): OptionsDraft =>
|
||||
readState<OptionsDraft>("b08-options", projectId) ?? {};
|
||||
|
||||
/** 지금 1본 길이 — 캐시 → 저장본. */
|
||||
export function liveOnePipe(projectId: string, saved: number | null): number | null {
|
||||
const draft = readDraft(projectId);
|
||||
return draft.one_pipe_length_m !== undefined ? draft.one_pipe_length_m : saved;
|
||||
}
|
||||
|
||||
/** 지금 연암 % — 캐시 → 저장본. */
|
||||
export function liveSoftRock(projectId: string, saved: number): number {
|
||||
return readDraft(projectId).soft_rock_pct ?? saved;
|
||||
}
|
||||
|
||||
/** host 가 화면에 붙어 있는 동안 옵션이 바뀔 때마다 fn — 떨어진 뒤 첫 알림에서 스스로 뗌. */
|
||||
export function onOptionsChange(host: HTMLElement, fn: () => void): void {
|
||||
const listener = (): void => {
|
||||
if (host.isConnected) fn();
|
||||
else changes.removeEventListener("change", listener);
|
||||
};
|
||||
changes.addEventListener("change", listener);
|
||||
}
|
||||
|
||||
const pctText = (value: number): string => String(Math.round(value * 1e6) / 1e6);
|
||||
|
||||
function card(id: string, title: keyof typeof ui_locales, ...body: HTMLElement[]): HTMLElement {
|
||||
const section = document.createElement("section");
|
||||
section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card";
|
||||
section.dataset.b08Options = id;
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "ui-collapsible__title";
|
||||
heading.textContent = L(title);
|
||||
section.append(heading, ...body);
|
||||
return section;
|
||||
}
|
||||
|
||||
function hint(text: string): HTMLElement {
|
||||
const p = document.createElement("p");
|
||||
p.className = "b08-options__hint";
|
||||
p.textContent = text;
|
||||
return p;
|
||||
}
|
||||
|
||||
/** 좌측 패널 — 맨 끝 `.ui-sidebar-actions` 줄([저장] · [?] 자리). `onSaved` = 열린 탭 다시 그림. */
|
||||
export function createOptionsPanel(projectId: string | null, onSaved: () => void): HTMLElement {
|
||||
const one = createInputField({
|
||||
label: "배관 1본 길이 (m)",
|
||||
type: "number",
|
||||
compact: true,
|
||||
min: 0,
|
||||
});
|
||||
const soft = createInputField({
|
||||
label: "연암 (%)",
|
||||
type: "number",
|
||||
compact: true,
|
||||
min: 0,
|
||||
max: 100,
|
||||
});
|
||||
const hard = createInputField({
|
||||
label: "경암 (%)",
|
||||
type: "number",
|
||||
compact: true,
|
||||
min: 0,
|
||||
max: 100,
|
||||
});
|
||||
one.input.dataset.b08Option = "one_pipe_length_m";
|
||||
soft.input.dataset.b08Option = "soft_rock_pct";
|
||||
hard.input.dataset.b08Option = "hard_rock_pct";
|
||||
const rockRow = document.createElement("div");
|
||||
rockRow.className = "b08-options__row";
|
||||
rockRow.append(soft.root, hard.root);
|
||||
|
||||
let saved: { one: number | null; soft: number } = { one: 8, soft: 90 };
|
||||
const show = (): void => {
|
||||
const draft = projectId ? readDraft(projectId) : {};
|
||||
const oneM = draft.one_pipe_length_m !== undefined ? draft.one_pipe_length_m : saved.one;
|
||||
const softPct = draft.soft_rock_pct ?? saved.soft;
|
||||
one.input.value = oneM == null ? "" : String(oneM);
|
||||
soft.input.value = pctText(softPct);
|
||||
hard.input.value = pctText(100 - softPct);
|
||||
};
|
||||
const put = (patch: OptionsDraft): void => {
|
||||
if (!projectId) return;
|
||||
writeState("b08-options", { ...readDraft(projectId), ...patch }, projectId);
|
||||
changes.dispatchEvent(new Event("change"));
|
||||
};
|
||||
|
||||
one.input.addEventListener("input", () => {
|
||||
const value = Number(one.input.value);
|
||||
put({ one_pipe_length_m: one.input.value.trim() && value > 0 ? value : null });
|
||||
});
|
||||
// 한쪽을 고치면 다른 쪽 = 100 − 값 · 칸을 떠나면 자른 값으로 다시 씀
|
||||
const pair = (from: HTMLInputElement, other: HTMLInputElement, isSoft: boolean): void => {
|
||||
from.addEventListener("input", () => {
|
||||
const value = from.value.trim() ? clampPct(Number(from.value)) : null;
|
||||
if (value === null) return;
|
||||
other.value = pctText(100 - value);
|
||||
put({ soft_rock_pct: isSoft ? value : 100 - value });
|
||||
});
|
||||
from.addEventListener("change", show);
|
||||
};
|
||||
pair(soft.input, hard.input, true);
|
||||
pair(hard.input, soft.input, false);
|
||||
|
||||
const saveAll = async (): Promise<void> => {
|
||||
if (!projectId) return;
|
||||
const draft = readDraft(projectId);
|
||||
if (draft.one_pipe_length_m === null) {
|
||||
showToast("배관 1본 길이를 넣으세요 — 0 보다 큰 값.", "error");
|
||||
return;
|
||||
}
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const body = await request<SavedOptions>(`/projects/${projectId}/quantity/options`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(draft),
|
||||
});
|
||||
saved = { one: body.one_pipe_length_m, soft: body.rock_ratios_pct.연암 };
|
||||
clearState("b08-options", projectId);
|
||||
show();
|
||||
showToast("수량 산출 옵션을 저장했습니다.", "success");
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
showToast(`저장하지 못했습니다 — ${String(error)}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
};
|
||||
|
||||
const save = createButton({
|
||||
label: L("B08_Quantity_Btn_Save"),
|
||||
variant: "filled",
|
||||
onClick: () => void saveAll(),
|
||||
});
|
||||
save.dataset.b08OptionsSave = "";
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-sidebar-actions";
|
||||
actions.append(save);
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "ui-sidebar-panel b08-options";
|
||||
panel.append(
|
||||
card("structure", "B08_Quantity_Tab_Structure", one.root),
|
||||
card("earthwork", "B08_Quantity_Tab_Earthwork", rockRow, hint("암 전체 기준 · 두 칸 합 100")),
|
||||
card("hydrology", "B08_Quantity_Tab_Hydrology", hint("다음 옵션 자리")),
|
||||
actions,
|
||||
);
|
||||
attachCollapsible(panel);
|
||||
show();
|
||||
|
||||
if (projectId)
|
||||
request<SavedOptions>(`/projects/${projectId}/quantity/options`).then(
|
||||
(body) => {
|
||||
saved = { one: body.one_pipe_length_m, soft: body.rock_ratios_pct.연암 };
|
||||
show();
|
||||
},
|
||||
(error: unknown) => showToast(`옵션을 불러오지 못했습니다 — ${String(error)}`, "error"),
|
||||
);
|
||||
return panel;
|
||||
}
|
||||
@@ -37,3 +37,16 @@
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 좌측 패널 옵션(122) — 연암 · 경암 두 칸 나란히 · 설명 한 줄 */
|
||||
.b08-options__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b08-options__hint {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 구조물 아래 보조 탭 = 구조물 집계표(61-1) · 총괄자재수량집계표 · 콘크리트소요자재집계표 ·
|
||||
* 자재수량집계표(셋 = 121-1 `B08_Quantity_UI_Material`) — 구조물도는 집계표 행을 눌러 여는 모달(95-3).
|
||||
* 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀.
|
||||
* 좌측 패널 = 메인 탭과 같은 컨테이너 셋 + [저장] 줄(`B08_Quantity_UI_Options` · PLAN 122).
|
||||
* ========================================================================== */
|
||||
|
||||
import "@ui/ui_template_general_layout.css";
|
||||
@@ -20,6 +21,7 @@ import { renderStructureSummary } from "./B08_Quantity_UI_Summary";
|
||||
import { renderMaterialSheet } from "./B08_Quantity_UI_Material";
|
||||
import { renderHydro } from "./B08_Quantity_UI_Hydro";
|
||||
import { renderEarthwork } from "./B08_Quantity_UI_Earthwork";
|
||||
import { createOptionsPanel } from "./B08_Quantity_UI_Options";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -101,7 +103,9 @@ export function renderB08Quantity(root: HTMLElement): void {
|
||||
panel.className = "b08-quantity__panel";
|
||||
panel.setAttribute("role", "tabpanel");
|
||||
|
||||
let current: TabId = "structure";
|
||||
const select = (tab: TabId): void => {
|
||||
current = tab;
|
||||
tabBar.replaceChildren(
|
||||
...TABS.map(({ id, label }) => {
|
||||
const button = createButton({
|
||||
@@ -128,6 +132,8 @@ export function renderB08Quantity(root: HTMLElement): void {
|
||||
steps: workflowSteps(),
|
||||
activeStep: 5,
|
||||
mainContent: body,
|
||||
// [저장] 뒤 열린 탭을 서버 정본으로 다시 그림
|
||||
leftPanel: createOptionsPanel(projectId, () => select(current)),
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
onStepClick: (stepIndex: number) => {
|
||||
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* 조합 열에서 뺌 · 다른 구조물 열은 조합 그대로
|
||||
* 열 폭 = 맨 아래 단 글(+ 칸 값)만 잼 · 위 단 머리는 줄바꿈(`B08_Quantity_UI_Page.css` · 120-2)
|
||||
* 줄을 누르면 그 측점의 구조물도 모달(PLAN 95-3 · `B08_Quantity_UI_Drawing` 은 누를 때 `import()`)
|
||||
* 커플링 열 = 좌측 패널 1본 길이로 바로 다시 셈(관마다 길이 `pipes` · `recountCouplings` · PLAN 122)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -35,6 +36,8 @@ import {
|
||||
type StationCombo,
|
||||
widthOf,
|
||||
} from "./B08_Quantity_Summary_Data";
|
||||
import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc";
|
||||
import { liveOnePipe, onOptionsChange } from "./B08_Quantity_UI_Options";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -67,6 +70,8 @@ interface PipeSummary {
|
||||
notes: string[];
|
||||
/** 서버가 펼친 종류(`type_id` · 관 지점은 `facility`) — 조합 열에서 뺌 */
|
||||
types: string[];
|
||||
one_length_m: number | null;
|
||||
pipes: PipeLength[];
|
||||
}
|
||||
|
||||
async function fetchPipeSummary(projectId: string): Promise<PipeSummary> {
|
||||
@@ -208,26 +213,35 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
|
||||
const things: PlacedItem[] = [...structures.structures, ...placed];
|
||||
const stations = summaryStations(things);
|
||||
// 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부)
|
||||
const summary = typeof pipe === "string" ? null : pipe;
|
||||
const served = new Set(summary?.types ?? []);
|
||||
const saved = typeof pipe === "string" ? null : pipe;
|
||||
const served = new Set(saved?.types ?? []);
|
||||
const others = [
|
||||
...structures.structures.filter((item) => !served.has(item.type_id)),
|
||||
...placed.filter((point) => !served.has(point.facility ?? "pipe")),
|
||||
];
|
||||
const columns = summaryColumns(others, describe);
|
||||
const live = (): PipeSummary | null =>
|
||||
saved && recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m));
|
||||
const summary = live();
|
||||
const parts: HTMLElement[] = [];
|
||||
if (typeof pipe === "string") parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${pipe}`));
|
||||
for (const text of summary?.notes ?? []) parts.push(note(text));
|
||||
if (!columns.length && !summary?.columns.length)
|
||||
parts.push(note(L("B08_Quantity_Summary_NoCombo")));
|
||||
parts.push(
|
||||
const draw = (): HTMLElement =>
|
||||
stations.length
|
||||
? buildTable(stations, columns, summary, comboPath, interval, (station, label) =>
|
||||
? buildTable(stations, columns, live(), comboPath, interval, (station, label) =>
|
||||
openSheets(projectId, label, combosAt(things, station), m02, describe),
|
||||
)
|
||||
: note(L("B08_Quantity_Summary_NoStation")),
|
||||
);
|
||||
: note(L("B08_Quantity_Summary_NoStation"));
|
||||
let table = draw();
|
||||
parts.push(table);
|
||||
host.replaceChildren(...parts);
|
||||
onOptionsChange(host, () => {
|
||||
const next = draw();
|
||||
table.replaceWith(next);
|
||||
table = next;
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
host.replaceChildren(note(`${L("B08_Quantity_Summary_Failed")} — ${reason}`));
|
||||
|
||||
@@ -256,6 +256,21 @@ def quantity_settings(project_root: str | Path) -> dict[str, Any]:
|
||||
return load_settings(project_root).get("quantity") or {}
|
||||
|
||||
|
||||
#: 연암 % 기본 — 암 전체 기준 · 경암 = 100 − 연암 · 파일에 안 쓰고 읽을 때 채움(PLAN 122)
|
||||
SOFT_ROCK_PCT_DEFAULT = 90.0
|
||||
|
||||
|
||||
def soft_rock_pct(settings: dict[str, Any]) -> float:
|
||||
"""연암 %(0~100) — `rock_ratios_pct` 연암 · 없으면 100 − 경암 · 둘 다 없으면 기본 90."""
|
||||
ratios = settings.get("rock_ratios_pct") or {}
|
||||
for name, flip in (("연암", False), ("경암", True)):
|
||||
value = ratios.get(name)
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
pct = min(max(float(value), 0.0), 100.0)
|
||||
return 100.0 - pct if flip else pct
|
||||
return SOFT_ROCK_PCT_DEFAULT
|
||||
|
||||
|
||||
def estimation_settings(project_root: str | Path) -> dict[str, Any]:
|
||||
"""B09 구획만 꺼낸다 — 범위 계수·장비 규격처럼 **사용자가 고른 단가 조건**이 여기 산다."""
|
||||
return load_settings(project_root).get("estimation") or {}
|
||||
|
||||
@@ -65,6 +65,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_r
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router
|
||||
from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router
|
||||
from B08_Quantity.B08_Quantity_Router_Hydro import router as b08_hydro_router
|
||||
from B08_Quantity.B08_Quantity_Router_Options import router as b08_options_router
|
||||
from B08_Quantity.B08_Quantity_Router_Summary import router as b08_summary_router
|
||||
from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router
|
||||
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
|
||||
@@ -651,6 +652,7 @@ app.include_router(b08_hydro_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_earthwork_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_summary_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_material_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_options_router, dependencies=protected_with_company)
|
||||
# B08 수량·B09 원가계산 라우터 13 개는 끊음 — 코드는 `old_code/` 에 있고 화면은 빈 틀만 뜬다(PLAN 7-3).
|
||||
# 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다.
|
||||
system_admin_only = [Depends(verify_session), Depends(require_system_admin)]
|
||||
|
||||
@@ -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 (
|
||||
'["절토", "연암"]',
|
||||
'["절토", "경암"]',
|
||||
'["측구파기", "토사"]',
|
||||
'["측구파기", "암"]',
|
||||
"리핑암 · 발파암 합",
|
||||
|
||||
@@ -210,6 +210,23 @@ def test_1본_길이는_프로젝트_층_변수(root, tmp_path, monkeypatch):
|
||||
assert summary.one_pipe_length(root) is None
|
||||
|
||||
|
||||
def test_관마다_길이_칸_화면이_1본_바꿔_다시_셈(root):
|
||||
# 122 — 응답 `pipes` = 관마다 길이 · 커플링 열(화면 recountCouplings 가 같은 식으로 셈)
|
||||
six, eight = _run(root, 6.0), _run(root, 8.0)
|
||||
assert six["pipes"] == eight["pipes"]
|
||||
assert [(round(p["chainage_m"]), p["column"], p["length_m"]) for p in six["pipes"]] == [
|
||||
(1433, "pipe:cp|1200", 22),
|
||||
(1567, "pipe:cp|800", 18),
|
||||
(1680, "pipe:cp|800", 16),
|
||||
(1790, "pipe:cp|800", None),
|
||||
]
|
||||
for out, one in ((six, 6.0), (eight, 8.0)):
|
||||
total = sum(
|
||||
summary.couplings(p["length_m"], one) or 0 for p in out["pipes"] if "800" in p["column"]
|
||||
)
|
||||
assert out["total"]["pipe:cp|800"] == total
|
||||
|
||||
|
||||
def test_라우터_등록():
|
||||
text = (Path(__file__).resolve().parents[2] / "main.py").read_text(encoding="utf-8")
|
||||
assert "app.include_router(b08_summary_router, dependencies=protected_with_company)" in text
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user