Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-09-14 21:29:14 +09:00
19 changed files with 505 additions and 29 deletions
@@ -39,8 +39,8 @@ from B06_Section.B06_Section_Repository import (
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from common_util.common_util_node_bundle import run_bundle_json
from common_util.common_util_project_settings import (
earthwork_conversion_factors,
haul_equipment_limits,
mixed_conversion_factors,
quantity_settings,
)
from common_util.common_util_storage import resolve_stored_project_path
@@ -96,7 +96,8 @@ async def conversion_factors_for(project_id: Any) -> dict[str, dict[str, float]]
except Exception:
logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 계수로 진행: project_id=%s", project_id)
return {kind: dict(entry) for kind, entry in EARTHWORK_CONVERSION_FACTORS.items()}
return earthwork_conversion_factors(quantity_settings(root))
# 암은 구성비 가중 C(㉱ (나)) — 토적표·운반표와 같은 함수.
return mixed_conversion_factors(quantity_settings(root))
async def haul_limits_for(project_id: Any) -> list[tuple[str, float | None]]:
@@ -235,7 +236,7 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
"detail": detail,
"context": _mass_haul_context(
haul_inputs,
earthwork_conversion_factors(quantity_settings(project_root)),
mixed_conversion_factors(quantity_settings(project_root)),
haul_equipment_limits(quantity_settings(project_root)),
),
},
@@ -52,6 +52,9 @@ FACE_DRESSING_FILL_SUGGESTED = (
"영월 실무 설계내역 「성토사면고르기 06M3 B/H」(백호 = 무한궤도 굴착기) · 임도는 산지라"
" 타이어식이 잘 안 들어감 — 타이어식도 고를 수 있음(2026-09-14 브레인 ②)",
)
#: 초류종자살포 비탈면 토질 — 품셈 5-24 씨앗뿜어붙이기의 잎 둘(5-24-1 일반 · 5-24-2 마사토) 이름 그대로.
#: 매핑 `leaf_from` 이 잎 코드로 잇고 비면 금액 없이 사유 · 제안값 없음(2026-09-14 브레인 ㉮).
SEED_SPRAY_GROUNDS = ("일반", "마사토")
#: 지장목제거 뿌리뽑기(9-21 제근) 굴착기 크기 — 품셈 9-21 표 갈래 0.2·0.7(무한궤도). 등급과 한 갈래.
#: ⚠ 제안값은 칸 곁에만(스스로 안 고름 · 비면 금액 없이 사유 — 2026-09-14 브레인 판정).
ROOT_REMOVAL_EXCAVATOR_SIZES = ("0.2", "0.7")
@@ -373,7 +376,7 @@ def _haul_rows(source: SummaryInput) -> 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 "")
ground = str(item.get("rock_class") or item.get("ground") or "")
distance = item.get("average_distance_m")
note = f"평균운반거리 {float(distance):.2f} m" if isinstance(distance, (int, float)) else ""
# ⚠ **자연상태로 싣는다** — 「운반거리 산정은 다짐상태, 내역서 수량은 자연상태」
@@ -116,6 +116,7 @@ def build_handoff(
face_dressing_cut_class: str | None = None,
face_dressing_fill_class: str | None = None,
root_removal_excavator_m3: str | None = None,
seed_spray_ground: str | None = None,
priced_sheets: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.
@@ -137,6 +138,8 @@ def build_handoff(
"face_dressing_fill_class": face_dressing_fill_class,
# 제근 굴착기 크기 — 등급과 「크기·등급」 한 갈래로(매핑 `variant_template` · 09-14).
"root_removal_excavator_m3": root_removal_excavator_m3,
# 초류종자살포 비탈면 토질 — 부모 5-24 의 잎(일반·마사토)을 고름(매핑 `leaf_from` · 09-14 ㉮).
"seed_spray_ground": seed_spray_ground,
}
rows, misses = _earthwork_rows(
summary_table, table, methods, bench_cut_depth_m, variant_inputs
@@ -164,12 +164,14 @@ def _earthwork_rows(
template_ready = all(inputs.get(name) for name in needs)
if template and template_ready:
variant_value = template.format(**inputs)
# 부모(갈래 고르기형)를 가리키는 매핑 — 설정값으로 잎 코드를 고름(초류종자살포 5-24 · 09-14 ㉮).
leaf_from = str((entry or {}).get("leaf_from") or "")
leaf_codes = (entry or {}).get("leaf_codes") or {}
leaf = leaf_codes.get(inputs.get(leaf_from)) if leaf_from else None
code = leaf or code
missing = not leaf if leaf_from else (not variant_value or not template_ready)
# 매핑이 「이 칸이 비면 못 고름」이라 적은 갈래 — 금액 없이 입력 사유(면고르기 · 09-14 Ⓒ).
if (
code
and (not variant_value or not template_ready)
and (entry or {}).get("variant_missing_reason")
):
if code and missing and (entry or {}).get("variant_missing_reason"):
blocked_kind = blocked_kind or BLOCKED_INPUT_MISSING
blocked_reason = blocked_reason or str(entry["variant_missing_reason"])
if code is None and not is_subtotal:
@@ -242,10 +244,13 @@ def _haul_rows(
# ⚠ 코드가 없으면 **줄에 막힘 표시를 단다**(2026-09-09) — 목록에만 실으면 줄 단위로
# 보는 쪽이 「멀쩡한 줄」로 읽어 금액이 조용히 빠진다(도자운반·덤프운반이 그랬다).
# ⚠ `in_bill` 이 False 인 무대 줄은 **막힌 것이 아니다** — 품에 포함이라 안 세우는 것.
haul_blocked = BLOCKED_UNIT_DATA_MISSING if (code is None and in_bill) else None
haul_blocked_reason = (
f"운반({equipment})의 품셈 공종을 아직 못 이었습니다" if haul_blocked else ""
no_code = code is None and in_bill # 암 줄 막힘은 운반표가 구성비로 가르며 단 것(㉱)
haul_blocked = (
BLOCKED_UNIT_DATA_MISSING if no_code else in_bill and row.get("blocked_kind") or None
)
haul_blocked_reason = str(in_bill and row.get("blocked_reason") or "")
if no_code:
haul_blocked_reason = f"운반({equipment})의 품셈 공종을 아직 못 이었습니다"
# ⚠⚠ **내역서 수량은 자연상태다** — 유토곡선은 다짐상태로 쌓고(운반거리를 그 기준으로
# 재야 맞는다) 내역에 오르는 수량은 되돌린 값이다(`config_system_design` 5-4-3
# 「운반거리 산정 시 모든 수량은 다짐상태로 환산해 계산하고, **내역서에 적용하는
@@ -270,7 +275,9 @@ def _haul_rows(
{
"work_item_code": code,
"name": f"{equipment} 운반",
"spec": str(row.get("ground") or ""),
"spec": " · ".join(
dict.fromkeys(filter(None, (row.get("rock_class"), row.get("ground"))))
),
"unit": "",
"quantity": quantity,
# 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다).
@@ -24,6 +24,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
WorkItemMapping,
)
from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import LOADING_EQUIPMENT
from B08_Quantity.B08_Quantity_Engine_RockSplit import split_amounts
SPOIL_NAME = "사토 운반"
#: ⚠ **사토장 사면 물량은 안 센다**(2026-09-09 세 창 확인). 교본 6장 3절은 「완료 구간 비탈면을
@@ -98,20 +99,28 @@ def spoil_haul_rows(
unknown = float(spoil.get("ground_unknown_m3") or 0.0)
if by_ground or unknown > 0:
rows: list[dict[str, Any]] = []
for label, amount in sorted(by_ground.items()):
leg = by_distance.get(label)
# 암은 운반표와 같은 구성비 몫으로(자리표시 리핑암을 값으로 안 씀 · ㉱ `RockSplit`).
shares = (haul_table or {}).get("rock_shares")
for label, amount, share_reason, rock_class, source in split_amounts(by_ground, shares):
leg = by_distance.get(source)
leg_blocked = blocked if leg is None else False
rows.append(
_spoil_row(
code,
amount,
distance if leg is None else leg,
leg_blocked,
reason if leg_blocked else "",
leg_blocked or bool(share_reason),
share_reason or (reason if leg_blocked else ""),
note,
ground=label,
extra=" · ".join(
(DISTANCE_FROM_SETTING if leg is None else DISTANCE_FROM_SITE, state_note)
part
for part in (
f"암 갈래 {rock_class}" if rock_class else "",
DISTANCE_FROM_SETTING if leg is None else DISTANCE_FROM_SITE,
state_note,
)
if part
),
)
)
@@ -254,6 +254,8 @@ def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]:
{
"equipment": row["equipment"],
"ground": row["ground"],
# 암 갈래(구성비로 가른 줄 · `RockSplit`) — 집계표 공종 칸이 흙깎기와 같은 이름을 씀.
"rock_class": row.get("rock_class"),
"volume_m3": row["volume_m3"],
"volume_basis": row.get("volume_basis") or "compacted",
"natural_m3": row.get("natural_m3"),
@@ -0,0 +1,110 @@
"""암 운반량을 **구성비로 가르기** — ㉱ (가) (2026-09-14 브레인 판정 · 8-1 사용자 확정).
B06 암은 ** 종류 자리표시** 토사 토글을 끄면 저장값이 `ripping_rock` 되지만 뜻은
이고 갈라 넣는 것은 설계내역 (`B06_Section_UI_Cross_Design.ts` 머리 주석). 그런데 운반표·사토가
이름(리핑암) **값처럼** 넘겨, 깎기 암은 구성비가 비어 막히는데 운반 암은 금액이 섰다.
운반표를 만든 ** **(`Router_Earthwork`)에서 줄을 흙깎기와 **같은 구성비**(`_split_by_rock`)
갈래별 시공법으로 가른다. 운반거리 ·토공집계 운반 ·인계·사토가 모두 표를 읽어 값이 된다.
유토곡선 거리·다짐 부피는 그대로(자리표시 C 쌓은 ) 부피를 몫대로 나눌 뿐이라 검산이 흔들림.
C 까지 구성비로 맞추는 것은 () 차례.
구성비가 비면 줄로 **막는다** 깎기와 같은 사유(지어낸 갈래로 금액을 세우지 않음).
"""
from __future__ import annotations
from typing import Any, Iterable
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput, _split_by_rock
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
BLOCKED_INPUT_MISSING,
METHOD_TO_GROUND,
NOTE_METHOD_MISSING,
NOTE_ROCK_RATIO_MISSING,
)
#: 유토곡선이 넘기는 암 이름 — 둘 다 자리표시로 본다(옛 자료 `blasting_rock` 도 「암」).
ROCK_GROUNDS = ("리핑암", "발파암")
#: 몫대로 나누는 수량 칸 — 운반표 줄(`HaulSummary.build_table`).
SHARED_KEYS = ("volume_m3", "natural_m3", "work_m3m")
def rock_shares(
classes: Iterable[str], ratios_pct: dict[str, Any], methods: dict[str, str | None]
) -> list[dict[str, Any]]:
"""갈래별 몫 — `{rock_class, fraction, ground, blocked_reason, note}`. 흙깎기와 같은 안분."""
source = SummaryInput(rock_classes=list(classes), rock_ratios_pct=dict(ratios_pct or {}))
shares = []
for name, fraction, note in _split_by_rock(1.0, source):
ground = "" if name == "" else METHOD_TO_GROUND.get(methods.get(name) or "")
if name == "":
reason = NOTE_ROCK_RATIO_MISSING
else:
reason = "" if ground else NOTE_METHOD_MISSING
shares.append(
{
"rock_class": name,
"fraction": fraction,
"ground": ground or name,
"blocked_reason": reason,
"note": note,
}
)
return shares
def split_rows(
rows: Iterable[dict[str, Any]], shares: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""암 줄을 몫마다 한 줄로 — 토사 줄은 그대로."""
out: list[dict[str, Any]] = []
for row in rows:
if row.get("ground") not in ROCK_GROUNDS:
out.append(row)
continue
for share in shares:
numbers = {
key: row[key] * share["fraction"]
for key in SHARED_KEYS
if isinstance(row.get(key), (int, float))
}
out.append(
{
**row,
**numbers,
"ground": share["ground"],
"rock_class": share["rock_class"],
"rock_split_note": share["note"],
"blocked_kind": BLOCKED_INPUT_MISSING if share["blocked_reason"] else None,
"blocked_reason": share["blocked_reason"],
}
)
return out
def split_amounts(
by_ground: dict[str, float], shares: list[dict[str, Any]] | None
) -> list[tuple[str, float, str, str | None, str]]:
"""갈래별 물량(사토) → `(갈래, 물량, 막힘 사유, 암 갈래, 원래 이름)`. 몫이 없으면 그대로."""
out = []
for label, amount in sorted(by_ground.items()):
if shares and label in ROCK_GROUNDS:
out.extend(
(s["ground"], amount * s["fraction"], s["blocked_reason"], s["rock_class"], label)
for s in shares
)
else:
out.append((label, amount, "", None, label))
return out
def apply_rock_split(
haul: dict[str, Any],
classes: Iterable[str],
ratios_pct: dict[str, Any],
methods: dict[str, str | None],
) -> None:
"""운반표(`rows`)를 자리에서 가르고 사토가 같은 몫을 쓰게 `rock_shares` 를 싣는다."""
shares = rock_shares(classes, ratios_pct, methods)
haul["rows"] = split_rows(haul.get("rows") or [], shares)
haul["rock_shares"] = shares
+16 -3
View File
@@ -36,6 +36,7 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import (
FACE_DRESSING_FILL_SUGGESTED,
ROOT_REMOVAL_EXCAVATOR_SIZES,
ROOT_REMOVAL_EXCAVATOR_SUGGESTED,
SEED_SPRAY_GROUNDS,
SummaryInput,
)
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table
@@ -43,6 +44,7 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_t
from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping
from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import check_against_plan, summary_input_rows
from B08_Quantity.B08_Quantity_Engine_RockSplit import apply_rock_split
from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_preparation_table
from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table
from B08_Quantity.B08_Quantity_Engine_SlopeLength import road_surface_area, station_slopes
@@ -53,10 +55,11 @@ from common_util.common_util_project_settings import (
application_ratio,
concrete_placing_method,
earthwork_conversion_choices,
earthwork_conversion_factors,
mixed_conversion_factors,
haul_limit_choice,
quantity_settings,
rock_classes,
rock_method,
save_section,
topsoil_target,
)
@@ -95,7 +98,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
)
# ⚠ 설정을 **먼저** 읽는다 — 토량환산계수를 프로젝트가 골랐으면 표가 그 값으로 서야 한다.
settings, project_root = await _project_settings(project_id)
factors = earthwork_conversion_factors(settings)
# 암은 구성비 가중 C(㉱ (나)) — 유토곡선(B06)·운반표와 같은 함수.
factors = mixed_conversion_factors(settings)
table = build_table(_stations(designs), factors)
# 화면이 「무엇을 골랐나 · 품셈 범위 안인가」를 보이는 데 쓴다. 계산에는 안 들어간다.
table["conversion_factor_choices"] = earthwork_conversion_choices(settings)
@@ -114,6 +118,10 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
plan = await _stored_haul_plan(project_id, route_id)
haul = build_haul_table(plan, factors)
# ㉱ (가) 암은 흙깎기와 같은 구성비·시공법으로 가름 — B06 리핑암은 자리표시(8-1 · 2026-09-14 브레인).
classes = rock_classes(settings)
methods = {name: rock_method(settings, name) for name in classes}
apply_rock_split(haul, classes, settings.get("rock_ratios_pct") or {}, methods)
# 사토 — **운반 줄이 되는 값**인데 유토곡선의 띠·이동에는 안 들어 있다(잔량으로 남는다).
# 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다.
# ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다.
@@ -183,6 +191,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
"basis": ROOT_REMOVAL_EXCAVATOR_SUGGESTED[1],
},
}
# 초류종자살포 비탈면 토질 — 5-24 잎 둘(서버 한 곳) · 제안값 없음(2026-09-14 브레인 ㉮).
table["seed_spray_choices"] = {"choices": list(SEED_SPRAY_GROUNDS)}
# 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨).
structures = await _route_structures(project_id)
table["preparation"] = build_preparation_table(
@@ -237,7 +247,7 @@ _GROUND_KIND_OF = {"ea_m3": "soil", "rr_m3": "ripping_rock", "br_m3": "blasting_
def _compacted_factor(settings: dict[str, Any]) -> dict[str, float]:
"""갈래 칸 ↔ 다짐 환산계수 `C` — 프로젝트가 고른 값이 있으면 그것이 선다."""
factors = earthwork_conversion_factors(settings)
factors = mixed_conversion_factors(settings)
return {key: float(factors[kind]["compacted"]) for key, kind in _GROUND_KIND_OF.items()}
@@ -490,6 +500,8 @@ class QuantitySettingsBody(BaseModel):
face_dressing_cut_class: str | None = None
# 제근 굴착기 크기 — "0.2"·"0.7"(품셈 9-21 갈래). `""` 는 「안 정함」.
root_removal_excavator_m3: str | None = None
# 초류종자살포 비탈면 토질 — "일반"·"마사토"(품셈 5-24 잎). `""` 는 「안 정함」.
seed_spray_ground: str | None = None
face_dressing_fill_class: str | None = None
# 면고르기 면적 덮어쓰기(㎡) — `None` 은 파종 면적을 그대로(2026-09-14 판정 Ⓐ).
face_dressing_fill_area_m2: float | None = None
@@ -569,6 +581,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
("face_dressing_cut_class", FACE_DRESSING_CUT_CLASSES),
("face_dressing_fill_class", FACE_DRESSING_FILL_CLASSES),
("root_removal_excavator_m3", ROOT_REMOVAL_EXCAVATOR_SIZES),
("seed_spray_ground", SEED_SPRAY_GROUNDS),
):
if key in values and values[key] not in choices:
values[key] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침
@@ -499,6 +499,8 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
face_dressing_fill_class=settings.get("face_dressing_fill_class") or None,
# 제근 굴착기 크기(0.2·0.7) — 비면 뿌리뽑기 줄이 입력 사유로 막힘(제안 0.7 은 칸 곁에만).
root_removal_excavator_m3=settings.get("root_removal_excavator_m3") or None,
# 초류종자살포 비탈면 토질 — 부모 5-24 의 잎을 고름. 비면 그 줄이 입력 사유로 막힘(09-14 ㉮).
seed_spray_ground=settings.get("seed_spray_ground") or None,
# 구조물도 양식 일위대가로 셀 장 — 그 구조물은 호표 `AX-ST` 줄 하나로(PLAN 6장 ②).
priced_sheets=_priced_sheets(project_root, unit_table, modes, settings),
)
@@ -102,6 +102,8 @@ export interface QuantitySettings {
stand_volume_class?: string | null;
/** 제근 굴착기 크기 — `"0.2"`·`"0.7"`(서버 선택지 안) · 비면 「안 정함」. */
root_removal_excavator_m3?: string | null;
/** 초류종자살포 비탈면 토질 — `"일반"`·`"마사토"`(5-24 잎) · 비면 「안 정함」. */
seed_spray_ground?: string | null;
/** 면고르기 갈래(서버 선택지 안) · 면적 덮어쓰기(㎡, 없음 = 파종 면적). */
face_dressing_cut_class?: string | null;
face_dressing_fill_class?: string | null;
@@ -164,6 +166,8 @@ export interface EarthworkTable {
conversion_factor_choices?: Record<string, ConversionFactorChoice>;
/** 면고르기 갈래 선택지 — 품셈 9-19-1 원문 표 두 벌(서버 한 곳). 화면은 그대로 보임. */
face_dressing_choices?: { cut: string[]; fill: string[] };
/** 초류종자살포 비탈면 토질 선택지(5-24 잎 둘) — 서버 한 곳 · 제안값 없음. */
seed_spray_choices?: { choices: string[] };
/** 제근 굴착기 크기 선택지·제안(회색 · [제안값 넣기]) — 서버 한 곳. */
root_removal_excavator_choices?: {
choices: string[];
+9 -7
View File
@@ -43,6 +43,7 @@ import { renderStructureSummary } from "./B08_Quantity_UI_StructureSummary";
import { appendTreeWasteFields } from "./B08_Quantity_UI_Side_TreeWaste";
import { appendFaceDressingFields } from "./B08_Quantity_UI_Side_FaceDressing";
import { appendRootRemovalFields } from "./B08_Quantity_UI_Side_RootRemoval";
import { appendSeedSprayField } from "./B08_Quantity_UI_Side_SeedSpray";
import { appendBenchCutFields } from "./B08_Quantity_UI_Side_BenchCut";
/** locale 헬퍼 */
@@ -111,6 +112,7 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
topsoil_target: draft.topsoil_target,
stand_volume_class: draft.stand_volume_class,
root_removal_excavator_m3: draft.root_removal_excavator_m3,
seed_spray_ground: draft.seed_spray_ground,
// 면고르기 — 갈래 `""`·면적 `null` 도 그대로(「안 정함」·「파종 면적 그대로」로 되돌리는 길).
face_dressing_cut_class: draft.face_dressing_cut_class,
face_dressing_fill_class: draft.face_dressing_fill_class,
@@ -311,6 +313,8 @@ interface DraftSettings {
stand_volume_class: string;
// 제근 굴착기 크기 — "0.2"·"0.7"(서버 선택지) · `""` 는 「안 정함」. 칸은 `_Side_RootRemoval`.
root_removal_excavator_m3: string;
// 초류종자살포 비탈면 토질 — "일반"·"마사토"(5-24 잎) · `""` 는 「안 정함」. 칸은 `_Side_SeedSpray`.
seed_spray_ground: string;
// 면고르기 — 갈래 둘(서버 선택지) · 면적 덮어쓰기 둘(`null` = 파종 면적). 칸은 `_Side_FaceDressing`.
face_dressing_cut_class: string;
face_dressing_fill_class: string;
@@ -525,13 +529,10 @@ function buildQuantitySidePanel(
}
}
// ── 면고르기 — 밑수가 파종 면적이라 반영률 바로 밑. 선택지는 서버 목록 그대로(칸은 따로 뺀 파일).
appendFaceDressingFields(panel, draft, table?.face_dressing_choices, {
field,
optionalNumberField,
selectField,
hintRow,
});
// ── 초류종자살포 토질 · 면고르기 — 밑수가 파종 면적이라 반영률 바로 밑. 선택지는 서버 목록 그대로.
const sideHelpers = { field, optionalNumberField, selectField, hintRow };
appendSeedSprayField(panel, draft, table?.seed_spray_choices, sideHelpers);
appendFaceDressingFields(panel, draft, table?.face_dressing_choices, sideHelpers);
// ── 표토 두께 — 표토 운반 부피(제거 ㎡ × T)에 씀 · 제거 줄은 ㎡ 라 안 곱함(2026-09-13 「실무대로」) ──
// ⚠ 2026-09-08 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.**
@@ -1032,6 +1033,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
topsoil_target: (stored.topsoil_target as string) ?? "",
stand_volume_class: (stored.stand_volume_class as string) ?? "",
root_removal_excavator_m3: (stored.root_removal_excavator_m3 as string) ?? "",
seed_spray_ground: (stored.seed_spray_ground as string) ?? "",
face_dressing_cut_class: (stored.face_dressing_cut_class as string) ?? "",
face_dressing_fill_class: (stored.face_dressing_fill_class as string) ?? "",
face_dressing_fill_area_m2: (stored.face_dressing_fill_area_m2 as number | null) ?? null,
@@ -0,0 +1,40 @@
/* =============================================================================
* B08_Quantity_UI_Side_SeedSpray.ts
* · (2026-09-14 ).
*
* (5-24 · ) (·)
* . ( `leaf_from`).
* (`B08_Quantity_UI_Page.ts`) 700 (CLAUDE.md 4).
* ** **(`seed_spray_choices`) · .
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import type { SideFieldHelpers } from "./B08_Quantity_UI_Side_TreeWaste";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export function appendSeedSprayField(
panel: HTMLElement,
draft: { seed_spray_ground: string; dirty: boolean },
choices: { choices: string[] } | undefined,
h: SideFieldHelpers,
): void {
if (!choices) return;
panel.append(
h.selectField(
L("B08_Quantity_Side_SeedSpray_Label"),
draft.seed_spray_ground,
[
{ value: "", label: L("B08_Quantity_SeedSpray_Unset") },
...choices.choices.map((name) => ({ value: name, label: name })),
],
(picked) => {
draft.seed_spray_ground = picked;
draft.dirty = true;
},
),
h.hintRow(L("B08_Quantity_Side_SeedSpray_Hint")),
);
}
@@ -323,6 +323,36 @@ def earthwork_conversion_factors(settings: dict[str, Any]) -> dict[str, dict[str
return resolved
#: 암 시공법 → 환산계수 갈래 이름(`EARTHWORK_CONVERSION_FACTORS` 키).
ROCK_METHOD_KINDS = {ROCK_METHOD_RIPPING: "ripping_rock", ROCK_METHOD_BLASTING: "blasting_rock"}
def mixed_conversion_factors(settings: dict[str, Any]) -> dict[str, dict[str, float]]:
"""토적표·유토곡선·운반표가 **계산에** 쓸 계수 — 암은 구성비 가중 C (㉱ (나) · 2026-09-14).
B06 암은 종류 **자리표시**(`ripping_rock` · 자료 `blasting_rock`)이고 암질은 설계내역에서
구성비로 정함(8-1 사용자 확정). 그래서 구성비와 갈래별 시공법이 ** 서면** 칸을
Σ몫 × C(시공법) 갈음 몫은 흙깎기와 같은 안분( 비율끼리 · 합이 100 아니어도 안분).
구성비가 비거나 시공법이 빠진 갈래가 있으면 **종전 ** 인계가 막힘 사유를 내고, 지어낸 계수를 .
화면 무엇을 골랐나(`earthwork_conversion_choices`) 갈래별 그대로 여기 값을 보이면 .
"""
resolved = earthwork_conversion_factors(settings)
ratios = settings.get("rock_ratios_pct") or {}
shares = {
name: float(ratios.get(name) or 0)
for name in rock_classes(settings)
if name != "토사" and float(ratios.get(name) or 0) > 0
}
kinds = {name: ROCK_METHOD_KINDS.get(rock_method(settings, name) or "") for name in shares}
if not shares or not all(kinds.values()):
return resolved
total = sum(shares.values())
mixed = sum(shares[name] / total * resolved[kind]["compacted"] for name, kind in kinds.items())
for kind in ROCK_METHOD_KINDS.values():
resolved[kind]["compacted"] = mixed
return resolved
def earthwork_conversion_choices(settings: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""갈래별 「무엇을 골랐나」 — 화면이 기본값과 고른 값을 갈라 보이는 데 쓴다.
@@ -130,7 +130,11 @@
"work_item_code": "FP-05-24",
"master_name": "씨앗뿜어붙이기",
"basis_unit": "㎡",
"basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L2769·L2794 — 5-24-1·5-24-2 절 머리 바로 아래 「(㎡당)」. ⚠ 「단위:」 글자 없이 **괄호만** 적힌 모양이라 마스터가 못 읽은 자리(2026-09-08 원문 대조)."
"basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L2769·L2794 — 5-24-1·5-24-2 절 머리 바로 아래 「(㎡당)」. ⚠ 「단위:」 글자 없이 **괄호만** 적힌 모양이라 마스터가 못 읽은 자리(2026-09-08 원문 대조).",
"leaf_from": "seed_spray_ground",
"leaf_codes": {"일반": "FP-05-24-01", "마사토": "FP-05-24-02"},
"variant_missing_reason": "초류종자살포 비탈면 토질(일반·마사토)이 아직 입력되지 않았습니다 — 산출 조건에서 고르면 단가가 섭니다(품셈 5-24 씨앗뿜어붙이기: 5-24-1 기계/일반 · 5-24-2 기계/마사토)",
"leaf_note": "2026-09-14 브레인 ㉮ — 매핑이 부모(갈래 고르기형)라 B09 가 「잎 미선택」으로 막는데 고를 칸이 없었음 · 산출 조건 칸이 잎 코드를 고름 · 제안값 없음."
},
{
"group": "되메우기",
@@ -153,6 +153,8 @@ def test_집계표_입력으로_줄임() -> None:
"volume_basis",
# 쓴 계수도 함께 온다 — 받는 쪽이 되짚을 수 있어야 한다.
"conversion_c",
# 2026-09-14 ㉱ — 구성비로 가른 암 갈래(안 가른 줄은 None) · 집계표 공종 칸이 씀.
"rock_class",
}
for row in rows
)
@@ -0,0 +1,106 @@
"""㉱ (가) 암 운반량을 구성비로 가르기 — 2026-09-14 브레인 판정(구성비가 정본 · 8-1 사용자 확정).
B06 토사 토글 = 저장값 `ripping_rock` **자리표시**(갈라 넣는 것은 설계내역 )인데
운반·사토가 이름을 값처럼 써서 깎기 암은 구성비가 비어 막히는데 운반 암은 리핑암으로 금액이 .
운반표 곳에서 줄을 구성비·시공법으로 가름 · 비면 줄로 막음(깎기와 같은 사유).
유토곡선 거리·다짐 부피는 그대로 검산이 흔들림(() 차례).
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput # noqa: E402
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as summary # noqa: E402
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( # noqa: E402
NOTE_METHOD_MISSING,
NOTE_ROCK_RATIO_MISSING,
)
from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table, check_against_plan # noqa: E402
from B08_Quantity.B08_Quantity_Engine_RockSplit import apply_rock_split # noqa: E402
CLASSES = ["토사", "풍화암", "연암", "보통암", "경암"]
PLAN = {
"blocks": [
{
"bands": [
{"equipment": "dozer", "haul_distance_m": 40.0, "haul_from_m": 0.0,
"haul_to_m": 40.0, "ea_m3": 90.0, "rr_m3": 115.0, "br_m3": 0.0},
]
}
],
"transfers": [],
"hauled_m3": 205.0,
"transferred_m3": 0.0,
} # fmt: skip
SPOIL = {"volume_m3": 23.0, "distance_m": 300.0, "by_ground_m3": {"rr_m3": 23.0},
"natural_m3_by_ground": {"rr_m3": 20.0}} # fmt: skip
def _haul(ratios: dict, methods: dict) -> dict:
haul = build_table(PLAN)
haul["spoil"] = dict(SPOIL)
apply_rock_split(haul, CLASSES, ratios, methods)
return haul
def _rock(haul: dict) -> list[dict]:
return [r for r in haul["rows"] if r["ground"] != "토사"]
def test_구성비가_비면_운반_암도_한_줄_암으로_막힘() -> None:
haul = _haul({}, {})
(rock,) = _rock(haul)
assert rock["ground"] == "" and rock["natural_m3"] == pytest.approx(100.0)
assert rock["blocked_reason"] == NOTE_ROCK_RATIO_MISSING
items = build_handoff(haul_table=haul)["work_items"]
dozer = next(r for r in items if r["name"].endswith("운반") and r["ground_class"] == "")
assert (
dozer["blocked_kind"] == "input_missing"
and dozer["blocked_reason"] == NOTE_ROCK_RATIO_MISSING
)
spoil = next(r for r in items if r["name"] == "사토 운반")
assert (
spoil["blocked_kind"] == "input_missing"
and NOTE_ROCK_RATIO_MISSING in spoil["blocked_reason"]
)
def test_구성비와_시공법대로_갈리고_검산은_그대로() -> None:
ratios = {"연암": 60.0, "보통암": 40.0}
methods = {"연암": "ripping", "보통암": "blasting"}
haul = _haul(ratios, methods)
got = [(r["rock_class"], r["ground"], round(r["natural_m3"], 6)) for r in _rock(haul)]
assert got == [("연암", "리핑암", 60.0), ("보통암", "발파암", 40.0)]
assert check_against_plan(haul, PLAN).difference_m3 == pytest.approx(0.0)
items = build_handoff(haul_table=haul)["work_items"]
dozer = [r for r in items if r["haul_equipment"] == "dozer" and r["ground_class"] != "토사"]
assert [(r["spec"], r["variant_value"], r["blocked_kind"]) for r in dozer] == [
("연암 · 리핑암", "리핑암", None),
("보통암 · 발파암", "발파암", None),
]
spoil = [r for r in items if r["name"] == "사토 운반"]
assert [(r["variant_value"], round(r["quantity"], 3)) for r in spoil] == [
("리핑암", 12.0),
("발파암", 8.0),
]
# 깎기와 같은 몫 — 흙깎기 암 1000 이 같은 구성비로 600 · 400
rows = summary(SummaryInput(earthwork_totals={"cut_rock_volume_m3": 1000.0},
rock_classes=CLASSES, rock_ratios_pct=ratios))["rows"] # fmt: skip
cut = {r["item"]: r["amount"] for r in rows if r["group"] == "흙깎기" and r["item"] != "토사"}
assert cut == {"연암": 600.0, "보통암": 400.0}
def test_시공법을_안_고른_갈래만_막힘() -> None:
haul = _haul({"연암": 50.0, "경암": 50.0}, {"연암": "ripping"})
rows = {r["rock_class"]: r for r in _rock(haul)}
assert not rows["연암"]["blocked_reason"]
assert rows["경암"]["blocked_reason"] == NOTE_METHOD_MISSING
@@ -0,0 +1,66 @@
"""초류종자살포 잎 고르기 — 2026-09-14 브레인 차례 ㉮(B08 나머지 탭 훑기).
매핑이 부모(FP-05-24 씨앗뿜어붙이기 · 갈래 고르기형) 가리켜 B09 미선택으로 막는데 고를 칸이 없었음
금액이 영영 (면고르기와 같은 ). 산출 조건 초류종자살포 비탈면 토질(5-24-1 일반 · 5-24-2 마사토)
· 매핑 `leaf_from`·`leaf_codes` 코드로 잇고 · 비면 금액 없이 입력 사유 · 제안값 없음(지어내지 않음).
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SEED_SPRAY_GROUNDS # noqa: E402
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
SUMMARY = {"rows": [{"group": "초류종자살포", "item": "", "spec": "씨드스프레이", "unit": "",
"amount": 1500.0, "application_ratio_pct": 100.0}]} # fmt: skip
def _row(**inputs) -> dict:
rows = build_handoff(summary_table=SUMMARY, **inputs)["work_items"]
return next(r for r in rows if r["name"] == "초류종자살포")
def test_토질을_고르면_잎_코드로_감() -> None:
assert _row(seed_spray_ground="일반")["work_item_code"] == "FP-05-24-01"
row = _row(seed_spray_ground="마사토")
assert row["work_item_code"] == "FP-05-24-02" and not row["blocked_kind"], row
def test_토질이_비면_부모_코드에_금액_없이_입력_사유() -> None:
row = _row()
assert row["work_item_code"] == "FP-05-24"
assert row["blocked_kind"] == "input_missing", row
assert "일반·마사토" in row["blocked_reason"] and "산출 조건" in row["blocked_reason"]
def test_선택지는_마스터_잎_이름과_같음() -> None:
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
leaves = {
n["work_item_code"]: n["name"].split("/")[-1]
for n in load_work_item_master()["work_items"]
if n.get("parent_code") == "FP-05-24"
}
assert leaves == {"FP-05-24-01": "일반", "FP-05-24-02": "마사토"}
assert set(SEED_SPRAY_GROUNDS) == set(leaves.values())
def test_산출_조건이_받고_선택지_밖은_안_정함으로() -> None:
source = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Earthwork.py").read_text("utf-8")
assert '("seed_spray_ground", SEED_SPRAY_GROUNDS)' in source
assert 'table["seed_spray_choices"]' in source
material = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Material.py").read_text("utf-8")
assert 'seed_spray_ground=settings.get("seed_spray_ground") or None' in material
def test_화면_칸이_있고_저장_읽기에_실림() -> None:
page = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.ts").read_text("utf-8")
assert "seed_spray_ground: draft.seed_spray_ground" in page
assert "seed_spray_ground: (stored.seed_spray_ground" in page
assert "appendSeedSprayField(" in page
@@ -0,0 +1,66 @@
"""㉱ (나) 암 환산계수를 구성비 가중으로 — 2026-09-14 브레인 판정(구성비가 정본 · (가) 뒤 차례).
B06 암은 종류 자리표시(`ripping_rock`) 토적표 보정량·유토곡선이 리핑암 C 하나로 쌓았음
구성비 60/40(리핑·발파)이면 토적표 보정량이 217.43 (936be972 실측). 계수를 내는 곳에서
칸을 Σ몫×C(시공법) · 구성비나 시공법이 비면 종전 (인계가 막힘 사유를 · 지어낸 계수 ).
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1].parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from common_util.common_util_project_settings import ( # noqa: E402
earthwork_conversion_choices,
earthwork_conversion_factors,
mixed_conversion_factors,
)
MIX = {
"rock_class_set": "geochang5",
"rock_ratios_pct": {"연암": 60, "보통암": 40},
"rock_methods": {"연암": "ripping", "보통암": "blasting"},
}
def test_구성비와_시공법이_서면_암_두_칸이_가중_C() -> None:
factors = mixed_conversion_factors(MIX)
for kind in ("ripping_rock", "blasting_rock"):
assert factors[kind]["compacted"] == pytest.approx(0.6 * 1.15 + 0.4 * 1.30)
assert factors["soil"] == earthwork_conversion_factors(MIX)["soil"]
def test_고른_계수_위에서_가중() -> None:
settings = {**MIX, "conversion_factors_override": {"ripping_rock": {"compacted": 1.0}}}
assert mixed_conversion_factors(settings)["ripping_rock"]["compacted"] == pytest.approx(
0.6 * 1.0 + 0.4 * 1.30
)
@pytest.mark.parametrize(
"settings",
[
{"rock_class_set": "geochang5"},
{**MIX, "rock_methods": {"연암": "ripping"}},
],
)
def test_구성비나_시공법이_비면_종전_값(settings: dict) -> None:
assert mixed_conversion_factors(settings) == earthwork_conversion_factors(settings)
def test_화면_고른_값_표시는_갈래별_그대로() -> None:
assert earthwork_conversion_choices(MIX)["ripping_rock"]["compacted"] == pytest.approx(1.15)
def test_토적표_유토곡선_운반표가_같은_함수를_씀() -> None:
prebuild = (ROOT / "B06_Section" / "B06_Section_Server_Calc_Prebuild.py").read_text("utf-8")
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
+6
View File
@@ -693,6 +693,12 @@ export const ui_locales_b2 = {
"⚠ 「육상」은 통상값이고 사용자 확정이 아닙니다(확정 3차 ④) — 품셈 9-13 의 18구분이 이 값으로 갈립니다",
"⚠ “Dry” is a customary default, not a user decision — it selects one of the 18 sub-items",
],
B08_Quantity_Side_SeedSpray_Label: ["초류종자살포 비탈면 토질", "Seed spray slope soil"],
B08_Quantity_SeedSpray_Unset: ["안 정함(금액 안 섬)", "Not set (no amount)"],
B08_Quantity_Side_SeedSpray_Hint: [
"품셈 5-24 씨앗뿜어붙이기가 5-24-1 기계/일반 · 5-24-2 기계/마사토 둘로 갈림 — 제안값 없음, 안 고르면 내역 줄이 입력 사유로 섭니다",
"Pumsem 5-24 splits into general / decomposed-granite soil — no suggestion; unset leaves the bill row with an input reason",
],
B08_Quantity_Side_RootRemoval_Label: ["제근 굴착기 크기", "Root removal excavator size"],
B08_Quantity_RootRemoval_Unset: ["안 정함(금액 안 섬)", "Not set (no amount)"],
B08_Quantity_RootRemoval_Suggest: ["제안(비우면 안 정함):", "Suggested (blank = not set):"],