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

This commit is contained in:
2026-09-09 06:38:13 +09:00
24 changed files with 1288 additions and 30 deletions
+16 -2
View File
@@ -22,7 +22,11 @@ from uuid import UUID
from fastapi import APIRouter, Body
from fastapi.responses import JSONResponse
from B06_Section.B06_Section_Server_Calc_Prebuild import BUNDLE, _mass_haul_context
from B06_Section.B06_Section_Server_Calc_Prebuild import (
BUNDLE,
_mass_haul_context,
haul_inputs_for,
)
from common_util.common_util_node_bundle import run_bundle_json
logger = logging.getLogger(__name__)
@@ -56,12 +60,22 @@ async def compute_haul_plan(
status_code=400,
content={"status": "error", "message": "측점 수가 너무 많습니다."},
)
# 구조물 몫(공제·잔토)을 **넘겨야** 사토가 줄고 는다 — 인자 없이 부르면 늘 `None` 이라
# 통로만 있고 값이 안 흐른다(2026-09-09 실측으로 드러난 자리).
haul_inputs = await haul_inputs_for(project_id)
try:
output = await asyncio.to_thread(
run_bundle_json,
BUNDLE,
_NPM_SCRIPT,
{"haul_plan_for": result, "context": _mass_haul_context()},
{
"haul_plan_for": result,
"context": _mass_haul_context(
haul_inputs.get("collected_stone_deduction_m3"),
haul_inputs.get("structure_spoil_m3"),
haul_inputs.get("structure_spoil_points"),
),
},
)
except Exception:
logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id)
+4 -1
View File
@@ -52,7 +52,10 @@ async function withDraftWalls(
projectId: string,
): Promise<SectionDetailResponse> {
const drafts = readPendingStructures(projectId);
if (!drafts) return withStructureAreas(detail);
// ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이
// 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도
// 횡단도에 아무것도 안 보이던 결함의 원인이다(창 둘에서 같은 증상, 실측 확인).
if (!drafts || !drafts.length) return withStructureAreas(detail);
const types = await fetchStructureTypes().catch(() => []);
const names = new Map(
types
@@ -39,6 +39,17 @@ interface ServerCalcInput {
haul_equipment_limits?: Parameters<typeof computeHaulPlan>[1];
/** 채집석 공제(㎥, 양수) — B08 이 낸다. `null`/없음은 「아직 안 옴」이다. */
collected_stone_deduction_m3?: number | null;
/** 구조물 터파기 잔토(㎥, 양수) — B08 이 낸다. 사토에 **더한다**. */
structure_spoil_m3?: number | null;
/** 측점별 잔토 — 오면 **그 자리**에 얹는다(운반거리가 맞다). */
structure_spoil_points?: Array<{
chainage_m: number;
spoil_m3: number;
/** 그 터파기의 토질 — B08 이 이미 판정한 값(품셈 9-13). `null` 이면 「지반 모름」. */
ground_type?: string | null;
ground_label?: string | null;
ground?: string | null;
}> | null;
};
}
@@ -56,6 +67,8 @@ if (input.haul_plan_for) {
// 그리기 코드가 손대지 않고 그대로 받는다. 전부 숫자·문자열이라 JSON 으로 오간다.
const plan = computeHaulPlan(input.haul_plan_for, input.context?.haul_equipment_limits, {
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
structure_spoil_points: input.context?.structure_spoil_points ?? null,
});
writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null }));
process.exit(0);
@@ -80,6 +93,8 @@ const result = conversion
const plan = result
? computeHaulPlan(result, input.context?.haul_equipment_limits, {
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
structure_spoil_points: input.context?.structure_spoil_points ?? null,
})
: null;
const massHaul = result
@@ -63,7 +63,27 @@ _AREA_KEYS = (
)
def _mass_haul_context(collected_stone_deduction_m3: float | None = None) -> dict[str, Any]:
async def haul_inputs_for(project_id: Any) -> dict[str, Any]:
"""B08 이 낸 **구조물 몫**(채집석 공제·구조물 잔토)을 받아 온다.
⚠ **여기서 다시 세지 않는다** — 두 값 다 B08 전개에서 나오는 것이라 이쪽이 세면
같은 계산이 두 벌이 된다(CLAUDE.md 5장). 못 읽으면 빈 값으로 두고 **0 으로 눅이지 않는다**.
"""
try:
from B08_Quantity.B08_Quantity_Router_Material import project_haul_inputs
data = await project_haul_inputs(project_id)
return data if isinstance(data, dict) else {}
except Exception:
logger.exception("구조물 몫(공제·잔토) 조회 실패 — 값 없이 진행: project_id=%s", project_id)
return {}
def _mass_haul_context(
collected_stone_deduction_m3: float | None = None,
structure_spoil_m3: float | None = None,
structure_spoil_points: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다.
⚠ 채집석 공제(`collected_stone_deduction_m3`)만 상수가 아니라 **B08 이 내는 값**이다.
@@ -85,6 +105,13 @@ def _mass_haul_context(collected_stone_deduction_m3: float | None = None) -> dic
# ⚠ B08 이 아직 이 값을 내지 않는다(2026-09-09) — 그때까지 `None`(아직 안 옴)이다.
# 값을 내기 시작하면 여기에 실어 주기만 하면 통로가 이어진다.
"collected_stone_deduction_m3": collected_stone_deduction_m3,
# 구조물 터파기 잔토(㎥, 양수) — **사토에 더한다**(공제는 빼고 이것은 더한다).
# 구조물 잔토는 사토에 한 번만 더한다.
# B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고,
# 더하는 자리는 유토곡선의 사토뿐이다.
"structure_spoil_m3": structure_spoil_m3,
# 측점별 잔토 — 오면 이쪽이 이긴다(구조물이 선 자리 잔량에 얹어 운반거리를 맞춘다).
"structure_spoil_points": structure_spoil_points,
}
@@ -126,10 +153,13 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
# 상세 만들기(파일 읽기 위주)와 DB 두 건은 서로 기다릴 이유가 없다 — 같이 보낸다.
# 원격 DB 라 순차로 내면 왕복이 그대로 더해진다(질의 하나 약 12ms, 2026-09-06 실측).
pool = get_db_pool()
response, stored_path, longitudinal_row = await asyncio.gather(
# 구조물 몫(채집석 공제·구조물 잔토)도 함께 받아 온다 — **B08 이 낸 값**이고, 안 넘기면
# 통로만 있고 값이 안 흐른다(2026-09-09 실측: 공제가 늘 `None` 이라 사토가 안 줄었다).
response, stored_path, longitudinal_row, haul_inputs = await asyncio.gather(
get_section_detail(project_uuid, route_id),
run_with_connection(get_project_storage_relative_path, project_uuid),
run_with_connection(get_longitudinal_section, project_uuid, route_id),
haul_inputs_for(project_uuid),
)
marks.append(("종횡단 상세+DB 조회(병렬)", time.perf_counter()))
payload = getattr(response, "model_dump", None)
@@ -159,7 +189,14 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
run_bundle_json,
BUNDLE,
_NPM_SCRIPT,
{"detail": detail, "context": _mass_haul_context()},
{
"detail": detail,
"context": _mass_haul_context(
haul_inputs.get("collected_stone_deduction_m3"),
haul_inputs.get("structure_spoil_m3"),
haul_inputs.get("structure_spoil_points"),
),
},
)
marks.append(("Node 번들(면적·유토곡선)", time.perf_counter()))
if not isinstance(output, dict):
@@ -256,6 +256,27 @@ export function wallStandsAt(owner: CrossSection, section: CrossSection, key: st
return spanCovers(revetSpanOfSpec(side), deltaM);
}
/**
* 링크된 관의 벽이 **이 카드에 실제로 서는가**. 하나도 안 서면 그 링크는 이 측점의
* 그림을 막을 이유가 없다 — 독립 벽(D경로)이 그려져야 한다.
*
* 왜 있나(2026-09-09) — 「관이 없는 측점인데도 구조물을 놓으면 아무것도 안 보인다」가
* 사용자에게 보이던 결함이었다. 겹침 방지 가드가 **링크가 있기만 하면** 막고 있었는데,
* 관이 아홉·열하나인 노선에서는 링크가 거의 모든 측점을 덮어 D경로가 통째로 죽었다.
* 가드를 없애지 않고 **좁힌다** — 벽이 실제로 서는 카드에서만 막는다.
*/
export function culvertWallsStandAt(owner: CrossSection, section: CrossSection): boolean {
const keys = ["inlet", "outlet"];
const counts = owner.design?.extra_wall_counts ?? {};
for (const [side, count] of Object.entries(counts)) {
const total = Math.max(Math.trunc(Number(count) || 0), 0);
for (let index = 0; index < total; index += 1) {
keys.push(side === "basin" ? `bextra${index}` : `extra${index}`);
}
}
return keys.some((key) => wallStandsAt(owner, section, key));
}
export function culvertLinkFor(
section: CrossSection,
sections: readonly CrossSection[],
+7 -1
View File
@@ -18,6 +18,7 @@ import {
appendFordPavementOverlay,
appendFordSurfaceDropPlan,
} from "./B06_Section_UI_Cross_Ford_Pavement";
import { culvertWallsStandAt } from "./B06_Section_UI_Cross_Culvert_Wire";
import { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment";
import {
appendCrossDesignOverlay,
@@ -422,7 +423,12 @@ export function createCrossSectionCard(
// 이 측점에 배관/숨김 기슭막이 세트가 직접 붙었거나(section.culvert) **연동으로
// 옆에서 이어져 온**(culvertLink) 경우엔 배관 경로가 그린다 — 옛 D경로는 건너뛴다
// (둘 다 그리면 이웃 카드에 벽이 겹친다 — 2026-08-28 이관 이중그리기 방지).
if (!section.culvert && !culvertLink) {
// ⚠ 가드를 **좁혔다**(2026-09-09) — 예전에는 링크가 **있기만 하면** 막았는데,
// 관이 아홉·열하나인 노선에서는 링크가 거의 모든 측점을 덮어 **구조물을 놓아도
// 횡단도에 아무것도 안 보였다**(사용자에게 보이던 결함). 겹침 방지라는 까닭은
// 그대로 두고, **그 링크의 벽이 이 카드에 실제로 설 때만** 막는다.
const linkedWallsHere = !!culvertLink && culvertWallsStandAt(culvertLink.source, section);
if (!section.culvert && !linkedWallsHere) {
const ownAdjust = revetOffset?.adjustFor(section, "own");
const ownLayout = computeRevetmentLayout(section, ownAdjust);
ownDesignTrim = ownLayout?.designTrim;
@@ -43,6 +43,11 @@ from __future__ import annotations
from typing import Any, Iterable
from B08_Quantity.B08_Quantity_Engine_BasisUnit import verify_unit_matches_basis
from B08_Quantity.B08_Quantity_Engine_Handoff_Spoil import spoil_haul_rows
from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import (
rubble_base_rows,
structure_earthwork_rows,
)
# ⚠ 파일만 갈랐고 **계약은 그대로다** — 종전에 이 이름으로 가져다 쓰던 곳이 그대로 돌게
# 여기서 다시 내보낸다(2026-09-08 분리).
@@ -105,6 +110,7 @@ def build_handoff(
ground_methods: dict[str, str | None] | None = None,
concrete_placing_method: str | None = None,
bench_cut_depth_m: float | None = None,
structure_trench_water: str | None = None,
) -> dict[str, Any]:
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**."""
table = mapping or load_mapping()
@@ -120,10 +126,23 @@ def build_handoff(
rows, misses = _haul_rows(haul_table, table)
work_items.extend(rows)
unmatched.extend(misses)
# 사토를 실어 내는 줄 — 유토곡선이 사토를 내는데 **운반 줄이 없었다**(2026-09-08).
# 띠·이동에서만 운반이 만들어져 사토 잔량이 어디에도 안 실렸다.
work_items.extend(spoil_haul_rows(haul_table, table))
if unit_quantity_table:
rows, misses = _structure_rows(unit_quantity_table, table)
work_items.extend(rows)
unmatched.extend(misses)
# 구조물이 낸 터파기·되메우기·잔토 — **공종 축으로 올린다.**
# ⚠ 종전에는 성분으로만 있고 아무도 안 받아 **내역서에 한 줄도 안 나갔다**
# (2026-09-08 B09 매김에서 드러남). 실무 토적집계에는 서는 줄이다(울진 D12~D14).
rows, misses = structure_earthwork_rows(unit_quantity_table, table, structure_trench_water)
work_items.extend(rows)
unmatched.extend(misses)
# 기초잡석 — 버림이 선 구조물에 함께 서는 공종(확정 3차 ②). 묶음 구조물은 제외한다.
rows, misses = rubble_base_rows(unit_quantity_table, table)
work_items.extend(rows)
unmatched.extend(misses)
# 준비공·사방공 — **못 내는 줄도 사유와 함께** 보낸다(빼면 빠진 줄이 안 보인다).
# B군 종단배수 — 겹침을 합친 연장으로 종류별 한 줄(위 `_length_rows` 주석).
@@ -145,6 +164,10 @@ def build_handoff(
# 갈래 세트 — 「연암」이 몇 갈래 중 하나인지 알아야 ④가 선다.
"ground_class_set": ground_class_set,
"ground_classes": list(ground_classes or []),
# 갈래 이름 별칭 — 우리 「리핑암」을 일위대가가 「파쇄암」으로 부른다. 이름만 못 이어
# 도자 운반 금액이 안 붙던 자리라(2026-09-08 B09 매김) **인계본에 함께 싣는다.**
# ⚠ 갈래 이름 자체는 안 바꾼다 — 흙깎기(FP-09-04)가 「리핑암」으로 서 있다.
"ground_class_aliases": (table.ground_aliases or {}).get("aliases") or {},
"ground_methods": dict(methods),
# 시공법을 안 정해 공종을 못 고른 갈래 — 화면이 이 목록으로 안내를 띄운다.
"missing_method_classes": sorted(
@@ -182,6 +182,9 @@ class WorkItemMapping:
unit_conversion: dict[str, Any] = field(default_factory=dict)
#: 배수관 — 관종별 공종·연장 키. 관 정본은 `pipe_points.json` 이다.
pipe: dict[str, Any] = field(default_factory=dict)
#: 갈래 이름 별칭 — 우리 「리핑암」 ↔ 일위대가 「파쇄암」처럼 **같은 것을 다른 이름**으로
#: 부르는 자리. ⚠ 갈래 이름 자체를 갈지 않는다(흙깎기 매핑이 그 이름으로 서 있다).
ground_aliases: dict[str, Any] = field(default_factory=dict)
def declared_units(self) -> dict[str, str]:
"""공종코드 → **매핑이 원문에서 읽어 적은 밑수 단위**. 적힌 줄만 낸다.
@@ -249,6 +252,7 @@ def load_mapping(path: Path | None = None) -> WorkItemMapping:
concrete_placing=payload.get("concrete_placing") or {},
pipe=payload.get("pipe") or {},
unit_conversion=(payload.get("composite") or {}).get("unit_conversion") or {},
ground_aliases=payload.get("ground_aliases") or {},
)
@@ -0,0 +1,128 @@
"""사토 — 실어 내는 줄 (2026-09-08).
⚠⚠ **유토곡선이 사토를 내는데 아무도 실어 내지 않고 있었다.** 운반 줄은 `blocks[].bands`
와 `transfers` 에서만 만들어지는데, 사토는 `residuals(kind="spoil")` 로 남아 **어느 쪽에도
없다.** 그래서 구조물 잔토를 사토에 얹어도(126.63㎥) **덤프 물량이 하나도 안 늘었다** —
채집석 공제도 사토를 줄이는 값이라 **끝까지 금액에 안 나타났다**.
실무에는 서는 줄이다 — 울진 대흥 1공구 토적집계 `D32 사토 1,281㎥`.
⚠ **거리는 품셈이 정하지 않는다.** 사토장까지 거리는 설계 입력(`spoil_site_distance_m`)이고,
안 정했으면 **막고 사유를 낸다** — 임의 거리를 넣으면 그대로 금액이 된다.
⚠ **지반 갈래는 유토곡선이 준 것만 쓴다.** 잔량이 갈래별 물량(`ea/rr/br`)을 들고 오면
**갈래마다 한 줄**로 세운다 — 덤프 단가가 토사·암으로 갈리기 때문이다. 갈래를 못 붙인
몫(`ground_unknown_m3`)은 **따로 한 줄**로 세우고 막는다. 토사로 눅이면 임의 단가가 된다.
"""
from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
BLOCKED_INPUT_MISSING,
ORIGIN_HAUL,
WorkItemMapping,
)
SPOIL_NAME = "사토 운반"
SPOIL_EQUIPMENT = "dump_truck"
DISTANCE_MISSING = (
"사토장까지 운반거리가 저장에 없어 값이 서지 않음 — 품셈이 정하는 값이 아니라 설계 입력임"
"(임의 거리를 넣으면 그대로 금액이 됨)"
)
GROUND_UNKNOWN = (
"지반 갈래를 못 붙인 몫 — 구조물 잔토 가운데 걸친 측점의 지반이 섞여 못 가른 것."
" ⚠ 토사로 눅이면 덤프 단가가 임의로 정해짐"
)
#: 잔량이 들고 오는 갈래 키 ↔ 우리 갈래 이름(흙깎기·운반이 쓰는 그 낱말).
GROUND_KEYS = {"ea_m3": "토사", "rr_m3": "리핑암", "br_m3": "발파암"}
def spoil_haul_rows(
haul_table: dict[str, Any] | None, mapping: WorkItemMapping
) -> list[dict[str, Any]]:
"""사토를 실어 내는 줄 하나. 사토가 없으면 줄도 없다."""
spoil = (haul_table or {}).get("spoil") or {}
volume = float(spoil.get("volume_m3") or 0.0)
if volume <= 0:
return []
distance = spoil.get("distance_m")
entry = mapping.for_haul(SPOIL_EQUIPMENT) or {}
code = entry.get("work_item_code")
blocked = distance is None or float(distance) <= 0
reason = DISTANCE_MISSING if blocked else ""
note = spoil.get("note") or ""
# 갈래별로 나눠 세운다 — 덤프 단가가 토사·암으로 갈린다. 갈래가 안 오면 종전처럼 한 줄.
by_ground = {
GROUND_KEYS[key]: float(value)
for key, value in (spoil.get("by_ground_m3") or {}).items()
if key in GROUND_KEYS and float(value or 0.0) > 0
}
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()):
rows.append(
_spoil_row(code, amount, distance, blocked, reason, note, ground=label, extra="")
)
if unknown > 0:
rows.append(
_spoil_row(
code,
unknown,
distance,
True,
GROUND_UNKNOWN,
note,
ground=None,
extra=GROUND_UNKNOWN,
)
)
return rows
return [_spoil_row(code, volume, distance, blocked, reason, note, None, GROUND_UNKNOWN)]
def _spoil_row(
code: str | None,
volume: float,
distance: Any,
blocked: bool,
reason: str,
note: str,
ground: str | None,
extra: str,
) -> dict[str, Any]:
"""사토 운반 줄 하나 — 갈래마다 같은 모양으로 낸다."""
return {
"work_item_code": code,
"name": SPOIL_NAME,
"spec": " · ".join(
part for part in (ground or "", "" if blocked else f"{float(distance):g}m") if part
),
"unit": "",
"quantity": round(volume, 3),
"quantity_gross": None,
"application_ratio_pct": None,
"application_ratio_breakdown": None,
"quantity_breakdown": None,
"ground_class": ground,
"haul_distance_m": None if blocked else float(distance),
"haul_equipment": SPOIL_EQUIPMENT,
"station_from": None,
"station_to": None,
"excavation_method": None,
"spec_detail": " · ".join(part for part in (note, extra) if part),
"composite_parts": None,
"structure_kind": None,
"blocked_kind": BLOCKED_INPUT_MISSING if blocked else None,
"blocked_reason": reason,
"variant_axis": None,
"variant_value": None,
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": "",
"composite_not_ready": None,
"in_bill": not blocked and code is not None,
"in_bill_reason": reason,
"origin": ORIGIN_HAUL,
}
@@ -0,0 +1,294 @@
"""구조물 터파기·되메우기·잔토 인계 줄 (2026-09-08).
⚠⚠ **빠뜨렸던 자리다.** 구조물 전개는 이 셋을 `destination: earthwork` 로 내는데,
토공집계표는 **토적표만** 읽어 만들어져 그 성분을 아무도 받지 않았다. 그래서 두께 식·
기초 몫을 아무리 맞춰도 **내역서에 한 줄도 안 나갔다**(2026-09-08 B09 매김에서 드러남).
실무 내역에는 서는 줄이다 — 울진 대흥 1공구 토적집계 D12~D14:
`구조물터파기 토사 1,248 / 암 30 ㎥` · `되메우기 739 ㎥`
⚠ **품셈 9-13 은 18구분이다**(토질 3 × 육상/용수 × 심도 3). 우리는 **심도만** 안다
(터파기 깊이 = 직고 + 기초 깊이). 토질·용수는 저장 제원에 칸이 없으므로 **지어내지 않고**
상위 코드로 세운 뒤 사유를 붙인다 — 심도 갈래는 미리 갈라 두어 칸이 생기는 날 바로 붙는다.
⚠ **잔토는 내역 줄로 세우지 않는다** — 사토로 실어 내는 몫이라 유토곡선(B06)이 세야 겹치지
않는다. 다만 **지금 그 통로가 없다**(채집석 공제만 있다). 값을 버리지 않고 `in_bill=False`
로 넘기며 그 사실을 사유에 적는다 — 빼 버리면 빠진 줄을 아무도 못 찾는다.
"""
from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
BLOCKED_INPUT_MISSING,
ORIGIN_EARTHWORK,
ORIGIN_STRUCTURE,
WorkItemMapping,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import GROUND_TYPE_LABEL
from common_util.common_util_excavation import (
WALL_BLINDING_DEPTH_M,
WALL_FOUNDATION_DEPTH_M,
)
#: 품셈 9-13 심도 갈래 — 원문 표기(`0~1m · 1~2m · 2~3m`)를 그대로 쓴다.
DEPTH_BANDS: tuple[tuple[float, str], ...] = ((1.0, "0~1m"), (2.0, "1~2m"), (3.0, "2~3m"))
DEPTH_OVER = "3m 초과"
TRENCH_GROUP = "구조물터파기"
RUBBLE_GROUP = "기초잡석"
BACKFILL_GROUP = "되메우기"
SPOIL_GROUP = "잔토처리"
#: ⚠ 남은 축은 **용수 하나**다(2026-09-08). 토질은 측점 설계값(`design.ground_type`)에서
#: 끌어오고, 심도는 구조물 제원(직고 + 기초 깊이)에서 나온다. 용수는 저장에 칸이 없어
#: **입력 칸이 서야 하는 자리**다 — 모른다고 「육상」으로 눅이지 않는다.
WATER_BLOCKED_REASON = (
"용수 유무가 저장에 없어 품셈 9-13 의 18구분 중 육상·용수 어느 쪽인지 못 고름"
" — 모른다고 육상으로 눅이지 않음"
)
GROUND_BLOCKED_REASON = "토질을 못 가름"
#: 품셈 9-13 의 18구분 — **토질 3 × 육상/용수 × 심도 3**. 코드 차례가 원문 그대로다
#: (육상토사 0~1·1~2·2~3 → 용수토사 셋 → 육상 암절취 셋 → … → 용수 발파암 셋).
GROUND_ORDER = ("soil", "ripping_rock", "blasting_rock")
WATER_ORDER = ("육상", "용수")
DEPTH_ORDER = ("0~1m", "1~2m", "2~3m")
#: ⚠ 3m 를 넘는 칸이 **원문에 없다** — 지어내지 않고 상위 코드로 두고 사유를 낸다.
DEPTH_OVER_REASON = "심도 3m 를 넘는 칸이 품셈 9-13 원문에 없음 — 상위 코드로 둠"
#: ⚠ **사용자 확정이 아니라 통상값**이다(2026-09-09 확정 3차 ④). 화면에도 그 사실이 뜨고
#: 사용자가 「용수」로 뒤집으면 코드가 한 칸 옮겨 간다.
WATER_DEFAULT_NOTE = "용수 유무 — 「육상」은 통상값이고 사용자 확정이 아님(확정 3차 ④)"
def trench_child_code(parent: str | None, ground: str | None, water: str, band: str) -> str | None:
"""품셈 9-13 의 18구분 중 한 칸. 축이 하나라도 없으면 `None`(상위 코드로 둔다)."""
if not parent or ground not in GROUND_ORDER or water not in WATER_ORDER:
return None
if band not in DEPTH_ORDER:
return None
index = (
GROUND_ORDER.index(ground) * len(WATER_ORDER) * len(DEPTH_ORDER)
+ WATER_ORDER.index(water) * len(DEPTH_ORDER)
+ DEPTH_ORDER.index(band)
)
return f"{parent}-{index + 1:02d}"
SPOIL_REASON = (
"사토로 실어 내는 몫이라 유토곡선(B06)이 세야 겹치지 않음 — ⚠ 지금 그 통로가 없어"
" 어디에도 안 실림. 값을 버리지 않고 사유와 함께 넘김"
)
def _depth_band(depth_m: float) -> str:
for limit, label in DEPTH_BANDS:
if depth_m <= limit:
return label
return DEPTH_OVER
def _foundation_depth(options: dict[str, Any]) -> float:
"""기초 깊이 — 「기초유」 0.5 · 「기초버림」 0.1 · 안 고르면 0(비탈분만)."""
value = str(options.get("foundation") or "").strip()
if value == "기초유":
return WALL_FOUNDATION_DEPTH_M
if value == "기초버림":
return WALL_BLINDING_DEPTH_M
return 0.0
def _row(**fields: Any) -> dict[str, Any]:
"""줄 한 벌 — 계약이 요구하는 칸을 **모두** 채운다(빌더마다 같은 모양이라야 한다)."""
row: dict[str, Any] = {
"work_item_code": None,
"name": "",
"spec": "",
"unit": "",
"quantity": 0.0,
"quantity_gross": None,
"application_ratio_pct": None,
"application_ratio_breakdown": None,
"quantity_breakdown": None,
"ground_class": None,
"haul_distance_m": None,
"haul_equipment": None,
"station_from": None,
"station_to": None,
"excavation_method": None,
"spec_detail": "",
"composite_parts": None,
"structure_kind": None,
"blocked_kind": None,
"blocked_reason": "",
"variant_axis": None,
"variant_value": None,
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": "",
"composite_not_ready": None,
"in_bill": True,
"in_bill_reason": "",
"origin": ORIGIN_EARTHWORK,
}
row.update(fields)
return row
def rubble_base_rows(
unit_quantity_table: dict[str, Any], mapping: WorkItemMapping
) -> tuple[list[dict[str, Any]], list[str]]:
"""기초잡석(품셈 12-25) — 구조물 전개가 낸 성분을 **한 줄로** 올린다.
⚠ **묶음으로 서는 구조물은 건너뛴다** — 옹벽 묶음에 이미 `FP-12-25` 조각이 있어
여기서 또 세우면 같은 잡석을 두 번 센다(타설 줄에서 이미 겪은 자리).
"""
total = 0.0
bases: list[str] = []
for structure in unit_quantity_table.get("structures") or []:
if mapping.composite_for(str(structure.get("type_id") or "")):
continue
for component in structure.get("components") or []:
if str(component.get("name") or "") != RUBBLE_GROUP:
continue
amount = float(component.get("amount") or 0.0)
if amount <= 0:
continue
total += amount
basis = str(component.get("basis") or "")
if basis and basis not in bases:
bases.append(basis)
if total <= 0:
return [], []
entry = mapping.for_earthwork(RUBBLE_GROUP, None)
code = (entry or {}).get("work_item_code")
return [
_row(
work_item_code=code,
name=RUBBLE_GROUP,
spec="구조물",
spec_detail=bases[0] if bases else "구조물 전개 합",
quantity=round(total, 3),
origin=ORIGIN_STRUCTURE,
in_bill=code is not None,
in_bill_reason="" if code else "품셈 공종을 아직 못 이었습니다",
)
], ([] if code else [RUBBLE_GROUP])
def structure_earthwork_rows(
unit_quantity_table: dict[str, Any],
mapping: WorkItemMapping,
water: str | None = None,
) -> tuple[list[dict[str, Any]], list[str]]:
"""구조물이 낸 터파기·되메우기·잔토를 **공종 축**으로 올린다.
`water` 는 「육상」·「용수」. 안 주면 「안 정함」이라 상위 코드로 두고 사유를 낸다.
"""
# ⚠ 키는 **(심도, 토질)** 뿐이다 — 근거 문구까지 키에 넣으면 같은 「암절취 · 2~3m」이
# 측점 문구가 다르다는 이유로 **두 줄로 갈린다**(2026-09-08 실화면에서 그랬다).
trench: dict[tuple[str, str | None], float] = {}
bases: dict[tuple[str, str | None], list[str]] = {}
backfill = 0.0
spoil = 0.0
for structure in unit_quantity_table.get("structures") or []:
options = structure.get("options") or {}
height = float(structure.get("height_m") or options.get("height_m") or 0.0)
band = _depth_band(height + _foundation_depth(options))
ground = structure.get("ground_type") or None
ground_basis = str(structure.get("ground_type_basis") or "")
for component in structure.get("components") or []:
if str(component.get("destination") or "") != "earthwork":
continue
name = str(component.get("name") or "")
amount = float(component.get("amount") or 0.0)
if amount <= 0:
continue
if name == "터파기":
key = (band, ground)
trench[key] = trench.get(key, 0.0) + amount
reasons = bases.setdefault(key, [])
if ground_basis and ground_basis not in reasons:
reasons.append(ground_basis)
elif name == "되메우기":
backfill += amount
elif name == "잔토처리":
spoil += amount
rows: list[dict[str, Any]] = []
unmatched: list[str] = []
if trench:
entry = mapping.for_earthwork(TRENCH_GROUP, None)
code = (entry or {}).get("work_item_code")
if code is None:
unmatched.append(TRENCH_GROUP)
order = [band for _, band in DEPTH_BANDS] + [DEPTH_OVER]
for (band, ground), amount in sorted(
trench.items(), key=lambda item: (order.index(item[0][0]), str(item[0][1] or ""))
):
if not amount:
continue
ground_basis = " · ".join(bases.get((band, ground)) or [])
label = GROUND_TYPE_LABEL.get(str(ground), str(ground)) if ground else None
child = trench_child_code(code, ground, str(water or ""), band)
if not ground:
reason = f"{GROUND_BLOCKED_REASON}{ground_basis}"
elif not water:
reason = WATER_BLOCKED_REASON
elif child is None:
reason = DEPTH_OVER_REASON
else:
reason = ""
detail = " · ".join(
part
for part in (
"구조물 전개 합",
ground_basis,
WATER_DEFAULT_NOTE if water == "육상" else "",
)
if part
)
spec = " · ".join(part for part in (label or "", water or "", f"심도 {band}") if part)
rows.append(
_row(
work_item_code=child or code,
name=TRENCH_GROUP,
spec=spec,
ground_class=label,
spec_detail=detail,
quantity=amount,
blocked_kind=BLOCKED_INPUT_MISSING if reason else None,
blocked_reason=reason,
in_bill=not reason,
in_bill_reason=reason,
)
)
if backfill > 0:
entry = mapping.for_earthwork(BACKFILL_GROUP, None)
code = (entry or {}).get("work_item_code")
if code is None:
unmatched.append(BACKFILL_GROUP)
rows.append(
_row(
work_item_code=code,
name=BACKFILL_GROUP,
spec="구조물",
spec_detail="구조물 전개 합",
quantity=backfill,
in_bill=code is not None,
in_bill_reason="" if code else "품셈 공종을 아직 못 이었습니다",
)
)
if spoil > 0:
rows.append(
_row(
name=SPOIL_GROUP,
spec="구조물",
spec_detail="구조물 전개 합 (터파기 − 되메우기)",
quantity=spoil,
in_bill=False,
in_bill_reason=SPOIL_REASON,
)
)
return rows, unmatched
@@ -0,0 +1,86 @@
"""유토곡선이 받아야 할 **구조물 몫** — 채집석 공제와 구조물 잔토 (2026-09-08).
⚠⚠ **B08 은 내기만 하고 빼거나 더하지 않는다.** 두 값 다 **양수 ㎥** 로 주고,
공제(빼기)와 사토 가산(더하기)은 **유토곡선(B06)에서 한 번씩만** 일어난다.
부호를 넘기면 받는 쪽에서 두 번 뒤집힌다 — 실무 시트가 `−274.66` 으로 적혀 있어
실제로 겪은 자리다.
채집석 공제는 사토에서 한 번만 뺀다.
구조물 잔토는 사토에 한 번만 더한다.
⚠ **측점별로도 낸다.** 총량 하나만 주면 받는 쪽이 잔량 크기에 비례해 나눌 수밖에 없고,
그러면 **운반거리가 틀어진다**(한 곳에 몰리면 거리가 어긋남 — 공제 때 이미 짚은 자리).
구조물은 구간(start~end)이라 **그 가운데 측점**을 자리로 본다.
⚠ **지반 갈래도 함께 낸다** — 새 판정이 아니라 **구조물터파기가 이미 쓰는 그 값**이다
(`design.ground_type` 에서 뽑아 「암절취 · 심도 2~3m」로 세운 그것). 그 터파기에서 나온
흙이 곧 이 잔토이므로 **같은 판정을 두 곳이 그대로 쓴다** — 두 벌로 짜면 갈린다.
⚠ **섞여서 못 고른 구조물은 「모름」(`None`)으로 보낸다** — 터파기에서 다수결로 안 고른
그 규칙 그대로다. 받는 쪽이 그 몫을 「모르는 몫」으로 드러내면 된다.
⚠ **한계도 그대로 넘어간다** — 「터파기 깊이가 암반 경계선보다 깊은지」는 보지 않는다.
터파기 토질에 이미 있는 한계이고 여기서 새로 생기는 것이 아니다.
"""
from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import COLLECTED_STONE_KEY, GROUND_TYPE_LABEL
#: 유토곡선이 읽는 칸 이름 — 양쪽이 같은 낱말을 써야 인계에서 어긋나지 않는다.
STRUCTURE_SPOIL_KEY = "structure_spoil_m3"
STRUCTURE_SPOIL_POINTS_KEY = "structure_spoil_points"
SPOIL_COMPONENT = "잔토처리"
def _center(structure: dict[str, Any]) -> float | None:
start, end = structure.get("start_m"), structure.get("end_m")
if start is None and end is None:
return None
values = [float(v) for v in (start, end) if v is not None]
return sum(values) / len(values)
def haul_inputs(unit_quantity_table: dict[str, Any] | None) -> dict[str, Any]:
"""(채집석 공제, 구조물 잔토, 측점별 잔토). 값이 없으면 `None` — 0 으로 눅이지 않는다.
⚠ `None` 과 `0.0` 은 다르다. 「아직 안 옴」과 「없음」을 받는 쪽이 갈라 봐야 한다.
"""
if not unit_quantity_table:
return {
COLLECTED_STONE_KEY: None,
STRUCTURE_SPOIL_KEY: None,
STRUCTURE_SPOIL_POINTS_KEY: [],
}
total = 0.0
points: list[dict[str, Any]] = []
for structure in unit_quantity_table.get("structures") or []:
amount = 0.0
for component in structure.get("components") or []:
if str(component.get("name") or "") != SPOIL_COMPONENT:
continue
amount += float(component.get("amount") or 0.0)
if amount <= 0:
continue
total += amount
chainage = _center(structure)
if chainage is None:
continue
# 지반 갈래 — 구조물터파기가 쓰는 그 판정을 그대로 싣는다(두 벌로 안 짠다).
ground = structure.get("ground_type") or None
points.append(
{
"chainage_m": chainage,
"spoil_m3": round(amount, 3),
"ground_type": ground,
"ground_label": GROUND_TYPE_LABEL.get(str(ground)) if ground else None,
"ground_basis": str(structure.get("ground_type_basis") or ""),
}
)
collected = unit_quantity_table.get(COLLECTED_STONE_KEY)
return {
COLLECTED_STONE_KEY: collected,
STRUCTURE_SPOIL_KEY: round(total, 3) if points or total else None,
STRUCTURE_SPOIL_POINTS_KEY: sorted(points, key=lambda row: row["chainage_m"]),
}
@@ -165,7 +165,11 @@ class MaterialRow:
if self.surcharge_included:
parts.append(NOTE_INCLUDED)
elif self.surcharge_pct is None:
# ⚠ **왜 미확보인지**를 함께 적는다 — 「표에 이름이 없음」과 「이 방식엔 안 붙임」은
# 할 일이 다르다(2026-09-09 콘크리트에서 갈린 자리).
parts.append(NOTE_RATE_MISSING)
if self.basis:
parts.append(self.basis)
elif self.basis:
parts.append(self.basis)
if self.supply == SUPPLY_OWNER and self.install_by is None:
@@ -215,6 +219,35 @@ def verify_single_surcharge(unit_quantity_table: dict[str, Any] | None) -> list[
return []
#: 할증표 이름과 우리 성분 이름이 **다른 자리** — 이름만 잇는다(값은 그대로).
#: ⚠ 리핑암↔파쇄암 때와 같은 처방이다. 이름을 바꾸면 다른 쪽(타설 줄·묶음 조각)이 어긋난다.
#: 철근: 품셈 1-3-1 의 「이형철근 3 %」는 **규격을 가리지 않는다** — D13·D16 이 같은 줄이다.
#: 콘크리트: **레미콘일 때만** 잇는다. 기계·인력 비빔은 시멘트·골재가 각각 할증되는 자리라
#: 레미콘 할증을 붙이면 틀린다 — 그때는 미확보로 두고 사유를 낸다.
REBAR_ALIASES = {"이형철근 D13": "이형철근", "이형철근 D16": "이형철근"}
CONCRETE_NAMES = ("콘크리트", "채움콘크리트", "버림콘크리트")
READY_MIXED = "ready_mixed"
CONCRETE_ALIAS = "레미콘"
CONCRETE_NOT_READY_NOTE = (
"타설 방식이 레미콘이 아니라 레미콘 할증을 붙이지 않음 — 비빔은 시멘트·골재가 각각 할증됨"
)
def surcharge_lookup_name(name: str, concrete_placing_method: str | None) -> tuple[str, str]:
"""(할증표에서 찾을 이름, 사유). 이름이 그대로면 사유는 빈 문자열이다."""
if name in REBAR_ALIASES:
return REBAR_ALIASES[name], "품셈 1-3-1 「이형철근」 — 규격을 가리지 않음"
if name in CONCRETE_NAMES:
if concrete_placing_method == READY_MIXED:
return CONCRETE_ALIAS, "타설 방식이 레미콘 — 할증표의 「레미콘」 줄로 봄"
# ⚠ **안 정한 것과 비빔을 가른다.** 안 정하면 타설 줄이 기본값(레디믹스트)으로 도는데
# 할증만 미확보로 두면 **같은 프로젝트에서 두 값이 어긋난다**(2026-09-09 실화면).
if not concrete_placing_method:
return CONCRETE_ALIAS, "타설 방식을 안 정해 기본값(레디믹스트)으로 봄 — 정하면 따라감"
return name, CONCRETE_NOT_READY_NOTE
return name, ""
def _collect(
unit_quantity_table: dict[str, Any],
) -> tuple[dict[tuple[str, str], MaterialRow], dict[str, int]]:
@@ -245,6 +278,7 @@ def build_table(
surcharge_table: SurchargeTable | None = None,
supply_map: dict[str, Any] | None = None,
extra_materials: Iterable[dict[str, Any]] = (),
concrete_placing_method: str | None = None,
) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양.
@@ -280,9 +314,10 @@ def build_table(
missing_install_by.append(name)
if row.surcharge_included:
continue
rate, basis = table.rate_for(name)
lookup, alias_note = surcharge_lookup_name(name, concrete_placing_method)
rate, basis = table.rate_for(lookup)
row.surcharge_pct = rate
row.basis = basis
row.basis = " · ".join(part for part in (basis, alias_note) if part)
if rate is None:
missing_rate.append(name)
@@ -265,6 +265,15 @@ def fill_concrete_mpa(options: dict[str, Any]) -> tuple[str, str]:
BLINDING_THICKNESS_M = 0.10
#: 기초잡석 두께(m) — 2026-09-09 사용자 확정 3차 ② 「0.2 m」.
#: ⚠ 품셈 12-25 는 **㎥당 품만** 주고 두께를 정하지 않는다(전 출처 소진). 폭은 **버림 폭과
#: 같고**(KCS 34 50 05 「버림 콘크리트의 폭은 잡석다짐의 폭과 동일」) 그 폭은 확정 ⑪ 로
#: 하단 길이다. ⇒ **기초잡석 = 버림 × (잡석두께 ÷ 버림두께)** 로 나온다 — 같은 폭·연장이라
#: 두께 비만 곱하면 된다. 관측 원단위로 오는 구조물(옹벽)도 같은 식으로 선다
#: (버림 0.15㎥/m ÷ 0.1 = 폭 1.5m ⇒ 잡석 0.30㎥/m — 확정 3차 ② 의 그 값).
RUBBLE_BASE_THICKNESS_M = 0.2
RUBBLE_BASE_NAME = "기초잡석"
#: 버림을 뺄 수 있는 칸 — 「기본은 넣고, 빼고 싶으면 뺀다」(사용자 확정 ⑭).
#: 저장 제원에 이 칸이 없으면 **넣는 쪽**이 기본이다.
BLINDING_OPTION_KEYS = ("blinding_concrete", "base_blinding")
@@ -279,9 +288,13 @@ DESTINATION = {
"야면석": "material",
"고임돌": "material",
"막자갈": "material",
"콘크리트": "unit_price",
"채움콘크리트": "unit_price",
"버림콘크리트": "unit_price",
# ⭐ 2026-09-09 사용자 확정 3차 ⑥ — **콘크리트는 자재 축에 세운다**(사용자 명시).
# ⚠ 타설 줄(품셈 12-1)은 **품만** 주고 재료를 안 준다(서브 일위대가도 재료 0원).
# 그래서 자재로 안 보내면 **재료비가 통째로 빠진다** — B09 매김에서 드러난 자리다.
# ⚠ 배합을 분해하지 않는 규칙(㉢)은 그대로다 — 「콘크리트 ㎥」에서 멈춘다.
"콘크리트": "material",
"채움콘크리트": "material",
"버림콘크리트": "material",
"모르터": "unit_price",
"거푸집": "unit_price",
"물구멍관": "material",
@@ -293,6 +306,9 @@ DESTINATION = {
# 있던 것을 줄로 꺼낸 것뿐이고, `material`·`earthwork` 어디에도 안 섞인다.
# ⚠ 「석적」은 정본에 없다(소광리 시트에만) — **안 낸다.**
"입적": "reference",
# 기초잡석(품셈 12-25) — 운반·부설·다짐 품이 붙는 **공종**이라 일위대가로 간다.
# 자재총괄로 보내면 같은 잡석이 재료로 한 번 더 선다.
RUBBLE_BASE_NAME: "unit_price",
}
# ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다.
@@ -342,6 +358,10 @@ class StructureQuantity:
#: 비어 있으면 종전대로 「m · 연장」으로 선다.
billing_unit: str = ""
billing_quantity: float = 0.0
#: 구조물이 놓인 자리의 지반 갈래(`soil`·`ripping_rock`·`blasting_rock`)와 그 판정 근거.
#: 품셈 9-13 구조물터파기의 **토질 축**이 이 값으로 갈린다. 못 가르면 `None` 이다.
ground_type: str | None = None
ground_type_basis: str = ""
#: 표준경사표 — 품셈 13-4-4 [주]⑪. 파일이 없으면 종전 기본값(0.3)으로 돈다.
@@ -1097,6 +1117,68 @@ def section_modes_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float,
return modes
#: 저장된 지반 갈래 ↔ 품셈 9-13 토질 3구분. **새 칸을 만들지 않는다** — 측점마다 이미
#: `design.ground_type` 이 저장돼 있고(재생성 사고 때 이 값이 비어 B08 이 통째로 0 이 됐던
#: 그 키다), 값 셋이 품셈 구분과 그대로 맞물린다(2026-09-08 조율 창 확인).
GROUND_TYPE_LABEL = {
"soil": "토사",
"ripping_rock": "암절취",
"blasting_rock": "발파암",
}
def ground_types_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, str]:
"""저장된 횡단 설계 목록 → `{측점: 지반갈래}`. 값이 없는 측점은 담지 않는다."""
grounds: dict[float, str] = {}
for item in designs or ():
design = item.get("design") if isinstance(item, dict) else None
ground = str((design or {}).get("ground_type") or "").strip()
if not ground:
continue
grounds[float(_num(item.get("chainage_m")))] = ground
return grounds
def ground_type_at(
structure: dict[str, Any], ground_types: dict[float, str] | None
) -> tuple[str | None, str]:
"""(토질, 근거). 구조물이 **걸친 측점 전부**를 보고 갈래가 하나일 때만 값을 낸다.
⚠ **판정 규칙 — 섞이면 안 고른다.** 구조물은 구간(start~end)이고 지반은 측점 값이라
한 구조물이 토사 측점과 암 측점에 걸칠 수 있다. 그때 다수결로 한쪽을 고르면 **임의값이
금액으로 굳는다**(암 단가가 몇 배다). 섞였다는 사실과 갈래별 측점 수를 근거에 적고
값은 `None` 으로 둔다 — 성절토·용수에서 지킨 그대로다.
⚠ 걸친 측점이 하나도 없으면(구간이 측점 사이에 통째로 들어간 짧은 구조물) **가장 가까운
측점**을 쓴다 — 그 사실도 근거에 적는다.
"""
if not ground_types:
return None, "측점 지반 갈래가 저장에 없어 못 가름"
start, end = _num(structure.get("start_m")), _num(structure.get("end_m"))
if end < start:
start, end = end, start
inside = {
chainage: kind for chainage, kind in ground_types.items() if start <= float(chainage) <= end
}
if not inside:
center = (start + end) / 2.0
nearest = min(ground_types, key=lambda chainage: abs(float(chainage) - center))
kind = ground_types[nearest]
return (
kind,
f"걸친 측점이 없어 가장 가까운 측점({nearest:g}m)의 {GROUND_TYPE_LABEL.get(kind, kind)}",
)
counts: dict[str, int] = {}
for kind in inside.values():
counts[kind] = counts.get(kind, 0) + 1
if len(counts) == 1:
kind = next(iter(counts))
return kind, f"걸친 측점 {len(inside)}곳이 모두 {GROUND_TYPE_LABEL.get(kind, kind)}"
breakdown = " · ".join(
f"{GROUND_TYPE_LABEL.get(kind, kind)} {count}" for kind, count in sorted(counts.items())
)
return None, f"걸친 측점의 지반이 섞여 못 가름 — {breakdown}"
def _section_mode_at(
structure: dict[str, Any], section_modes: dict[float, str] | None
) -> str | None:
@@ -1116,10 +1198,38 @@ def _section_mode_at(
return section_modes.get(nearest)
def _rubble_base_component(
components: list[Component], thickness_m: float | None
) -> Component | None:
"""기초잡석 한 줄 — **버림 폭이 곧 잡석다짐 폭**이라 두께 비로 낸다(확정 3차 ②).
⚠ 폭을 다시 세지 않는다. 버림이 이미 그 폭으로 서 있으므로 두께 비만 곱하면
**관측 원단위로 오는 구조물(옹벽)에도 같은 식이 선다** — 두 벌로 짜지 않는 자리다.
"""
thickness = RUBBLE_BASE_THICKNESS_M if thickness_m is None else float(thickness_m)
if thickness <= 0:
return None
blinding = next((item for item in components if item.name == "버림콘크리트"), None)
if blinding is None or blinding.amount <= 0:
return None
ratio = thickness / BLINDING_THICKNESS_M
return Component(
RUBBLE_BASE_NAME,
"",
blinding.amount * ratio,
DESTINATION[RUBBLE_BASE_NAME],
f"버림 {blinding.amount:.3f}× (잡석두께 {thickness:g} ÷ 버림두께"
f" {BLINDING_THICKNESS_M:g}) — 폭이 같음(KCS 34 50 05) · 두께는 사용자 확정 3차 ②"
" (품셈 12-25 는 ㎥당 품만 주고 두께를 정하지 않음)",
)
def build_table(
structures: Iterable[dict[str, Any]],
names: dict[str, str] | None = None,
section_modes: dict[float, str] | None = None,
ground_types: dict[float, str] | None = None,
rubble_base_thickness_m: float | None = None,
) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다."""
observed = load_observed_table()
@@ -1128,10 +1238,15 @@ def build_table(
for item in structures:
expanded_inputs.append(item)
expanded_inputs.extend(attachments_of(item))
quantities = [
expand(item, names, observed, _section_mode_at(item, section_modes))
for item in expanded_inputs
]
quantities = []
for item in expanded_inputs:
quantity = expand(item, names, observed, _section_mode_at(item, section_modes))
quantity.ground_type, quantity.ground_type_basis = ground_type_at(item, ground_types)
# 기초잡석 — 버림이 선 구조물에 함께 선다(전개식이든 관측 원단위든 같은 자리).
rubble = _rubble_base_component(quantity.components, rubble_base_thickness_m)
if rubble is not None:
quantity.components.append(rubble)
quantities.append(quantity)
violations = verify_no_mix_components(quantities)
totals: dict[str, dict[str, Any]] = {}
@@ -1163,6 +1278,10 @@ def build_table(
"billing_quantity": item.billing_quantity,
# 저장된 제원 — 형식(반중력식…)처럼 **뒤 단계가 읽어야 하는** 값이 여기 있다.
"options": item.options,
# 구조물이 놓인 자리의 지반 갈래 — **품셈 9-13 토질 3구분**이 이 값으로 갈린다.
# ⚠ 여기서 새로 만드는 값이 아니라 측점 설계값(`design.ground_type`)을 옮긴 것이다.
"ground_type": item.ground_type,
"ground_type_basis": item.ground_type_basis,
"notes": item.notes,
"components": [
{
+59 -1
View File
@@ -83,6 +83,10 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
settings, project_root = await _project_settings(project_id)
plan = await _stored_haul_plan(project_id, route_id)
haul = build_haul_table(plan)
# 사토 — **운반 줄이 되는 값**인데 유토곡선의 띠·이동에는 안 들어 있다(잔량으로 남는다).
# 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다.
# ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다.
haul["spoil"] = _spoil_of(plan, settings)
# 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.**
# 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다.
table["pipe_lengths"] = [
@@ -150,6 +154,49 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
return JSONResponse(content=table)
def _spoil_of(plan: dict[str, Any] | None, settings: dict[str, Any]) -> dict[str, Any]:
"""사토 — 실어 낼 물량과 거리. 유토곡선 결과에서 **다시 세지 않고 그대로** 가져온다.
⚠ `spoil_m3` 는 **공제·가산이 끝난 값**이다(채집석 공제는 빼고 구조물 잔토는 더한 뒤).
여기서 또 만지면 두 번 셈이 된다.
⚠ 자연방토(`natural_spoil_m3`)는 실어 내지 않는 몫이라 **뺀다**.
"""
haul_plan = (plan or {}).get("haul_plan") if isinstance(plan, dict) else None
source = haul_plan if isinstance(haul_plan, dict) else (plan or {})
total = float(source.get("spoil_m3") or 0.0)
natural = float(source.get("natural_spoil_m3") or 0.0)
volume = max(total - natural, 0.0)
# 지반 갈래 — 사토 잔량이 갈래별 물량을 들고 온다(2026-09-08 랩탑 메인). 갈래를 못 붙인
# 몫은 `ground_unknown_m3` 로 따로 온다. **여기서 안분하지 않는다** — 근거 없는 몫을
# 토사로 눅이면 덤프 단가가 임의로 정해진다.
grounds: dict[str, float] = {}
unknown = 0.0
for residual in source.get("residuals") or []:
if str(residual.get("kind") or "") != "spoil":
continue
for key in ("ea_m3", "rr_m3", "br_m3"):
value = float(residual.get(key) or 0.0)
if value > 0:
grounds[key] = grounds.get(key, 0.0) + value
unknown += float(residual.get("ground_unknown_m3") or 0.0)
note_parts = [f"사토 {total:,.2f}"]
if natural > 0:
note_parts.append(f"자연방토 {natural:,.2f}㎥ 뺀 값")
added = source.get("structure_spoil_added_m3")
if added:
note_parts.append(f"구조물 잔토 {float(added):,.2f}㎥ 얹힌 뒤")
deducted = source.get("collected_stone_deducted_m3")
if deducted:
note_parts.append(f"채집석 {float(deducted):,.2f}㎥ 빠진 뒤")
return {
"volume_m3": round(volume, 3),
"distance_m": settings.get("spoil_site_distance_m"),
"note": " · ".join(note_parts),
"by_ground_m3": {key: round(value, 3) for key, value in grounds.items()},
"ground_unknown_m3": round(unknown, 3),
}
async def _route_structures(project_id: UUID) -> list[dict[str, Any]]:
"""배치된 구조물 목록 — 사방 시설이 있는지 보려는 것뿐이다. 없으면 빈 목록."""
try:
@@ -219,11 +266,22 @@ class QuantitySettingsBody(BaseModel):
ancillary_counts: dict[str, float] | None = None
# 층따기 길이(깊이, m). 면적 × 이 값 = ㎥ (확정 2차 ①).
bench_cut_depth_m: float | None = None
# 사토장까지 운반거리(m). 유토곡선이 낸 사토를 **실어 내는 줄**이 이 값으로 선다.
spoil_site_distance_m: float | None = None
# 기초잡석 두께(m) — 확정 3차 ② 0.2. 폭은 버림 폭과 같다(KCS 34 50 05).
rubble_base_thickness_m: float | None = None
# 구조물터파기 용수 유무 — "육상"·"용수". ⚠ 기본 육상은 **통상값**이지 사용자 확정이 아니다.
structure_trench_water: str | None = None
#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**.
#: 빈 문자열로 되돌리는 칸(시공법·타설 방식)과 달리 숫자 칸은 되돌릴 값이 `None` 뿐이다.
NULLABLE_SETTING_KEYS = ("topsoil_thickness_m", "bench_cut_depth_m")
NULLABLE_SETTING_KEYS = (
"topsoil_thickness_m",
"bench_cut_depth_m",
"spoil_site_distance_m",
"rubble_base_thickness_m",
)
@router.put("/{project_id}/quantity/settings")
+74 -4
View File
@@ -32,13 +32,17 @@ from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping, summarize
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import section_modes_from_designs
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
ground_types_from_designs,
section_modes_from_designs,
)
from common_util.common_util_project_settings import (
concrete_placing_method,
quantity_settings,
rock_classes,
rock_method,
)
from B08_Quantity.B08_Quantity_Engine_HaulInputs import haul_inputs
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_structure_lengths import structure_lengths
from config.config_db import run_with_connection
@@ -81,6 +85,24 @@ def _collect_structures(
return targets, names, sorted(set(skipped))
async def _ground_types(project_id: UUID) -> dict[float, str]:
"""측점별 지반 갈래(`soil`·`ripping_rock`·`blasting_rock`).
구조물터파기(품셈 9-13) **토질 ** 값으로 갈린다. 단면유형과 같은 자리에서
오므로 읽는 방식도 같다 읽으면 표로 두고 판정이 가름 되게 한다.
"""
try:
context = await run_with_connection(get_workflow_route_context, project_id)
route_id = int((context or {}).get("route_id") or 0)
if not route_id:
return {}
designs = await run_with_connection(get_cross_section_designs, route_id)
except Exception:
logger.exception("B08 지반 갈래 조회 실패: project_id=%s", project_id)
return {}
return ground_types_from_designs(designs)
async def _section_modes(project_id: UUID) -> dict[float, str]:
"""측점별 단면유형(`left_cut` 등). 구조물이 **성토면인가 절토면인가**를 가릴 때 쓴다.
@@ -124,11 +146,19 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
content={"status": "error", "message": "구조물 정본을 읽지 못했습니다."},
)
unit_table = build_unit_table(structures, names, await _section_modes(project_id))
settings = quantity_settings(project_root)
unit_table = build_unit_table(
structures,
names,
await _section_modes(project_id),
await _ground_types(project_id),
settings.get("rubble_base_thickness_m"),
)
material_table = build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
# 콘크리트 할증은 **레미콘일 때만** 붙는다 — 방식이 이름을 가른다(확정 3차 ⑥).
concrete_placing_method=settings.get("concrete_placing_method"),
)
# 묶음으로 서는 구조물의 조각을 화면에도 보인다 — 코드만으로는 사람이 검증 못 한다.
handoff = build_handoff(unit_quantity_table=unit_table)
@@ -153,6 +183,36 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
)
async def project_haul_inputs(project_id: UUID) -> dict[str, Any]:
"""유토곡선(B06)이 받아야 할 **구조물 몫** — 채집석 공제 · 구조물 잔토.
**B06 함수를 부르면 된다.** B08 전개에서 나오는 것이라 저쪽이 다시
세면 같은 계산이 벌이 된다(CLAUDE.md 5). 값은 **양수 ** 이고 빼고 더하는 것은
받는 몫이다. 읽으면 (`None`) 0 으로 눅이지 않는다.
"""
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
project_root = resolve_stored_project_path(stored_path)
structures, names, _skipped = _collect_structures(project_root)
unit_table = build_unit_table(
structures,
names,
await _section_modes(project_id),
await _ground_types(project_id),
quantity_settings(project_root).get("rubble_base_thickness_m"),
)
except Exception:
logger.exception("B08 유토곡선 입력 조회 실패: project_id=%s", project_id)
return haul_inputs(None)
return haul_inputs(unit_table)
@router.get("/{project_id}/quantity/haul-inputs")
async def get_haul_inputs(project_id: UUID) -> JSONResponse:
"""같은 값을 화면·다른 창이 볼 수 있게 낸 자리. 계산은 위 함수 한 벌이다."""
return JSONResponse(content=await project_haul_inputs(project_id))
@router.get("/{project_id}/quantity/handoff")
async def get_handoff(project_id: UUID) -> JSONResponse:
"""B09 로 넘길 두 벌 — 작업 공종 축과 자재 축 (일감 9).
@@ -174,10 +234,18 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
)
structures, names, skipped = _collect_structures(project_root)
unit_table = build_unit_table(structures, names, await _section_modes(project_id))
settings = quantity_settings(project_root)
unit_table = build_unit_table(
structures,
names,
await _section_modes(project_id),
await _ground_types(project_id),
settings.get("rubble_base_thickness_m"),
)
material_table = build_material_table(
unit_table, supply_map=settings.get("material_supply") or {}
unit_table,
supply_map=settings.get("material_supply") or {},
concrete_placing_method=settings.get("concrete_placing_method"),
)
# 토공·운반 표는 토적표 라우터의 것을 그대로 쓴다 — 여기서 다시 만들지 않는다.
@@ -205,6 +273,8 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
concrete_placing_method=concrete_placing_method(settings)[0],
# 층따기 길이 — 면적 × 이 값으로 ㎥ 를 낸다(확정 2차 ①). 안 넣었으면 막히고 사유가 감.
bench_cut_depth_m=settings.get("bench_cut_depth_m"),
# 용수 유무 — 기본 「육상」은 **통상값**이다(확정 3차 ④). 사유·화면에 그 사실이 뜬다.
structure_trench_water=settings.get("structure_trench_water"),
)
handoff["summary"] = summarize(handoff)
handoff["skipped_structures"] = skipped
@@ -67,6 +67,14 @@ export interface QuantitySettings {
concrete_placing_method?: string | null;
/** 표토 두께(m). `null`·없음이면 **안 정한 것**이라 표토제거 줄이 「근거 없음」으로 선다. */
topsoil_thickness_m?: number | null;
/** 층따기 길이(m) — 면적 × 이 값 = ㎥ (확정 2차 ①). 비면 층따기 줄이 막힌다. */
bench_cut_depth_m?: number | null;
/** 기초잡석 두께(m) — 확정 3차 ② 0.2. 폭은 버림 폭과 같다(KCS 34 50 05). */
rubble_base_thickness_m?: number | null;
/** 사토장까지 거리(m) — 현장값. 비면 사토 운반 줄이 막힌다. */
spoil_site_distance_m?: number | null;
/** 구조물터파기 용수 — `"육상"`·`"용수"`. ⚠ 「육상」은 통상값이지 사용자 확정이 아니다. */
structure_trench_water?: string | null;
}
export interface EarthworkTable {
@@ -184,6 +184,8 @@ const CSS = `
.b08-quantity__message { margin: 0; padding: 16px; font-size: 13px; color: var(--color-text-secondary); }
.b08-quantity__field { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; padding: 2px 0; }
.b08-quantity__field-value { color: var(--color-text-secondary); font-variant-numeric: tabular-nums; }
/* 칸 밑 근거 한 줄 — 왜 그 값인지 화면에서 보이게 한다(2026-09-09 사용자 지시). */
.b08-quantity__hint { margin: 0 0 6px; font-size: 11px; line-height: 1.4; color: var(--color-text-secondary); }
`;
/** 스타일을 한 번만 넣는다 — 페이지를 다시 그려도 중복되지 않는다. */
+83
View File
@@ -86,6 +86,11 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
concrete_placing_method: draft.concrete_placing_method,
// ⚠ `null` 도 그대로 보낸다 — 「안 정함」으로 되돌릴 길이 있어야 한다(시공법과 같은 규칙).
topsoil_thickness_m: draft.topsoil_thickness_m,
// 확정 2차 ① · 3차 ②③④ — 값이 화면 칸에서 오고, 비면 그 줄이 막힌다.
bench_cut_depth_m: draft.bench_cut_depth_m,
rubble_base_thickness_m: draft.rubble_base_thickness_m,
spoil_site_distance_m: draft.spoil_site_distance_m,
structure_trench_water: draft.structure_trench_water,
}),
},
);
@@ -105,6 +110,14 @@ function field(label: string, value: string): HTMLElement {
return row;
}
/** 칸 밑에 붙는 **근거 한 줄** — 왜 그 값인지 화면에서 보이게 한다(2026-09-09 사용자 지시). */
function hintRow(text: string): HTMLElement {
const row = document.createElement("p");
row.className = "b08-quantity__hint";
row.textContent = text;
return row;
}
/** 반영률 키 → 사람이 읽는 이름. 서버 키를 그대로 보이면 설계자가 못 읽는다. */
const RATIO_LABEL_KEYS: Record<string, keyof typeof ui_locales> = {
fill_slope_compaction: "B08_Quantity_Ratio_FillCompaction",
@@ -228,6 +241,14 @@ interface DraftSettings {
concrete_placing_method: string;
// 표토 두께(m) — `null` 은 「안 정함」. 정해야 표토제거 줄이 선다(품셈 9-15 [주]② 의 T).
topsoil_thickness_m: number | null;
// 층따기 길이(m) — 면적 × 이 값 = ㎥ (확정 2차 ①). `null` 이면 층따기 줄이 막힌다.
bench_cut_depth_m: number | null;
// 기초잡석 두께(m) — 확정 3차 ② 0.2. 폭은 버림 폭과 같다(KCS 34 50 05).
rubble_base_thickness_m: number | null;
// 사토장까지 거리(m) — 현장값. `null` 이면 사토 운반 줄이 막힌다(확정 3차 ③ 는 미정).
spoil_site_distance_m: number | null;
// 구조물터파기 용수 — "육상"·"용수". ⚠ 「육상」은 **통상값**이지 사용자 확정이 아니다.
structure_trench_water: string;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
material_supply: Record<string, SupplyChoice>;
dirty: boolean;
@@ -408,6 +429,64 @@ function buildQuantitySidePanel(
),
);
// ── 구조물·사토 — 확정 2차 ① · 3차 ②③④ 의 값들 ──────────────────────
// ⚠ 사용자 지시(2026-09-09): **값을 코드에 박고 끝내지 말고 화면에 칸으로 세우고
// 지금 값과 근거를 보이고 바꿀 수 있게 할 것.** 정한 값이 화면에 안 보이면 다음 사람이
// 왜 그 값인지 모른다.
panel.append(field(L("B08_Quantity_Side_Structure"), ""));
panel.append(
optionalNumberField(
L("B08_Quantity_Side_BenchCut_Label"),
draft.bench_cut_depth_m,
"0.01",
(value) => {
draft.bench_cut_depth_m = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_BenchCut_Hint")));
panel.append(
optionalNumberField(
L("B08_Quantity_Side_Rubble_Label"),
draft.rubble_base_thickness_m,
"0.05",
(value) => {
draft.rubble_base_thickness_m = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_Rubble_Hint")));
panel.append(
optionalNumberField(
L("B08_Quantity_Side_SpoilDistance_Label"),
draft.spoil_site_distance_m,
"10",
(value) => {
draft.spoil_site_distance_m = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_SpoilDistance_Hint")));
panel.append(
selectField(
L("B08_Quantity_Side_Water_Label"),
draft.structure_trench_water,
[
{ value: "", label: L("B08_Quantity_Water_Unset") },
{ value: "육상", label: L("B08_Quantity_Water_Dry") },
{ value: "용수", label: L("B08_Quantity_Water_Wet") },
],
(value) => {
draft.structure_trench_water = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_Water_Hint")));
// ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ──
panel.append(field(L("B08_Quantity_Side_Placing"), ""));
panel.append(
@@ -659,6 +738,10 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
rock_methods: { ...((stored.rock_methods ?? {}) as Record<string, string>) },
concrete_placing_method: (stored.concrete_placing_method as string) ?? "",
topsoil_thickness_m: (stored.topsoil_thickness_m as number | null) ?? null,
bench_cut_depth_m: (stored.bench_cut_depth_m as number | null) ?? null,
rubble_base_thickness_m: (stored.rubble_base_thickness_m as number | null) ?? null,
spoil_site_distance_m: (stored.spoil_site_distance_m as number | null) ?? null,
structure_trench_water: (stored.structure_trench_water as string) ?? "",
material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) },
dirty: false,
};
+164 -1
View File
@@ -199,6 +199,13 @@ export interface HaulPlan {
collected_stone_deduction_m3: number | null;
/** 실제로 사토에서 뺀 양(㎥). 사토가 모자라면 받은 값보다 작을 수 있다. */
collected_stone_deducted_m3: number;
/**
* (, ) ** **. `null`
* `0` ( ).
*/
structure_spoil_m3: number | null;
/** 실제로 사토에 더한 양(㎥). 받을 잔량이 없으면 받은 값보다 작을 수 있다. */
structure_spoil_added_m3: number;
/** 블록 안에서 옮기는 양(㎥). */
hauled_m3: number;
/** 떨어진 구간끼리 장거리로 옮기는 양(㎥). */
@@ -334,6 +341,9 @@ function bandOutline(
* ** **. ( )
* ** .**
* (`residuals`) balloon· .
* ** .**
* ** ** . .
* **** `applyStructureSpoil` **** . .
*
* ** ()**. .
*/
@@ -367,10 +377,150 @@ function applyCollectedStoneDeduction(residuals: HaulResidual[], deduction: numb
return deduction - Math.max(left, 0);
}
/**
* ** **(2026-09-09 ).
*
* .
* B08 (structure_spoil_m3, ) ,
* .
*
* ** ** ** **( ).
* ** ** B08 ** ** .
* ** ** . ,
* ( ).
* **(`natural_m3`) ** .
* ** (ea/rr/br) ** .
* (`volume_m3`) .
*
* ** ()**. 0 .
*/
/**
* . `null`( ).
* B08 9-13 3 .
*/
function groundBucket(ground: string | null | undefined): "ea_m3" | "rr_m3" | "br_m3" | null {
const text = (ground ?? "").trim();
if (!text) return null;
if (text === "토사" || text === "soil" || text === "ea") return "ea_m3";
if (text === "암절취" || text === "리핑암" || text === "ripping_rock" || text === "rr") {
return "rr_m3";
}
if (text === "발파암" || text === "blasting_rock" || text === "br") return "br_m3";
return null;
}
function newSpoilResidual(fromM: number, toM: number, volumeM3: number): HaulResidual {
// 잔토를 담을 사토 잔량이 없을 때 **새로 세운다**(2026-09-09 확정 ㉰).
// ⚠ 지반유형 안분·자연방토는 0 이다 — 어느 지반에서 나온 흙인지 모르고, **실어 내는** 흙이다.
return {
index: 0,
kind: "spoil",
from_m: fromM,
to_m: toM,
volume_m3: volumeM3,
level_from_m3: 0,
level_to_m3: 0,
ea_m3: 0,
rr_m3: 0,
br_m3: 0,
natural_m3: 0,
};
}
/**
* ** **(2026-09-09 ).
*
* .
* B08 (structure_spoil_m3, ) ,
* .
*
* ** ** ** **.
* ** **( ).
* ** **. () ** **
*
* ** ()** .
* ** ** .
* · , .
*/
function applyStructureSpoil(
residuals: HaulResidual[],
amount: number | null,
points: Array<{
chainage_m: number;
spoil_m3: number;
/** B08 이 보내는 이름은 `ground_type`(soil·ripping_rock·blasting_rock). 옛 이름·라벨도 받는다. */
ground_type?: string | null;
ground_label?: string | null;
ground?: string | null;
}> | null,
): number {
const spoilsOf = (): HaulResidual[] => residuals.filter((residual) => residual.kind === "spoil");
// ① 측점별 값이 오면 **그 자리**에 얹는다 — 구조물이 실제로 선 자리라 운반거리가 맞다.
if (points && points.length) {
let added = 0;
for (const point of points) {
const value = Number(point?.spoil_m3);
const chainage = Number(point?.chainage_m);
if (!Number.isFinite(value) || value <= 0 || !Number.isFinite(chainage)) continue;
const spoils = spoilsOf();
const covering = spoils.find(
(residual) => chainage >= residual.from_m - 1e-9 && chainage <= residual.to_m + 1e-9,
);
// 터파기 토질이 함께 오면 **그 갈래로 담는다** — B08 이 이미 판정한 값을 이어받는 것이라
// 새 근거를 만드는 것이 아니다. 안 오면 갈래 없이 담겨 「지반 모름」으로 남는다.
const bucket = groundBucket(point?.ground_type ?? point?.ground ?? point?.ground_label);
const target = covering ?? newSpoilResidual(chainage, chainage, 0);
if (!covering) residuals.push(target);
target.volume_m3 += value;
if (bucket) target[bucket] += value;
added += value;
}
return added;
}
// ② 총량만 오면 남은 사토 잔량 크기에 **비례**해 나눈다(어느 자리인지 모를 때의 차선).
if (amount === null || !Number.isFinite(amount) || amount <= 0) return 0;
const spoils = spoilsOf();
const total = spoils.reduce((sum, residual) => sum + residual.volume_m3, 0);
if (!spoils.length || total <= EPSILON) {
// 담을 사토가 없다 — 있는 잔량이 덮는 구간 전체에 사토를 하나 세운다. 자리를 모르므로
// 구간을 넓게 잡고, **물량은 보존**한다.
const froms = residuals.map((residual) => residual.from_m);
const tos = residuals.map((residual) => residual.to_m);
const fromM = froms.length ? Math.min(...froms) : 0;
const toM = tos.length ? Math.max(...tos) : 0;
residuals.push(newSpoilResidual(fromM, toM, amount));
return amount;
}
let added = 0;
spoils.forEach((residual, index) => {
const share =
index === spoils.length - 1 ? amount - added : amount * (residual.volume_m3 / total);
residual.volume_m3 += share;
added += share;
});
return added;
}
export function computeHaulPlan(
result: MassHaulResult,
limits: HaulEquipmentLimit[] | undefined,
options?: { collected_stone_deduction_m3?: number | null },
options?: {
collected_stone_deduction_m3?: number | null;
structure_spoil_m3?: number | null;
/** 측점별 구조물 잔토 — 오면 **이쪽이 이긴다**(그 자리 잔량에 얹어 운반거리를 맞춘다). */
structure_spoil_points?: Array<{
chainage_m: number;
spoil_m3: number;
/** B08 `design.ground_type` ( 9-13 3).
* ** **. `null`
* , (`ground_unknown_m3`) . */
ground_type?: string | null;
ground_label?: string | null;
ground?: string | null;
}> | null;
},
): HaulPlan | null {
const points = result.points;
if (points.length < 2) return null;
@@ -549,6 +699,17 @@ export function computeHaulPlan(
residual.index = index + 1;
});
// ⚠ **순서가 뜻을 가른다 — 「잔토를 더하고 → 공제를 뺀다」**(2026-09-09 확정).
// 유토곡선의 사토는 「현장에 남는 흙 총량」이고 **출처를 안 가린다**. 공제는 그 총량에서
// 빼는 것이라 **잔토가 담긴 뒤라야 뺄 대상이 있다.** 반대 순서로 두면 사토가 0 인
// 노선(전 구간 토취)에서 공제가 **영영 안 걸린다** — 두 창 저장분에서 실제로 그랬다.
const structureSpoilInput = options?.structure_spoil_m3 ?? null;
const structureSpoilPoints = options?.structure_spoil_points ?? null;
const structureSpoilAdded = applyStructureSpoil(
settled,
structureSpoilInput,
structureSpoilPoints,
);
const deductionInput = options?.collected_stone_deduction_m3 ?? null;
const deducted = applyCollectedStoneDeduction(settled, deductionInput);
const remaining = settled.filter((residual) => residual.volume_m3 > EPSILON);
@@ -577,6 +738,8 @@ export function computeHaulPlan(
natural_spoil_m3: naturalSpoil,
collected_stone_deduction_m3: deductionInput,
collected_stone_deducted_m3: deducted,
structure_spoil_m3: structureSpoilInput,
structure_spoil_added_m3: structureSpoilAdded,
hauled_m3: blocks.reduce((sum, block) => sum + block.volume_m3, 0),
transferred_m3: transfers.reduce((sum, entry) => sum + entry.volume_m3, 0),
fill_total_m3: fillTotal,
@@ -179,6 +179,13 @@ export function haulPlanPayload(plan: HaulPlan): Record<string, unknown> {
hauled_m3: round(plan.hauled_m3),
transferred_m3: round(plan.transferred_m3),
fill_total_m3: round(plan.fill_total_m3),
// 구조물 몫 — **받은 값**과 **실제로 먹은 값**을 함께 남긴다. 「통로만 있고 값이 안 흐른다」를
// 저장분에서 바로 가릴 수 있어야 한다(2026-09-09 그 사고가 세 번 났다).
// `null` 은 「아직 안 옴」, `0` 은 「없음」이다 — 눅이지 않는다.
collected_stone_deduction_m3: plan.collected_stone_deduction_m3,
collected_stone_deducted_m3: round(plan.collected_stone_deducted_m3),
structure_spoil_m3: plan.structure_spoil_m3,
structure_spoil_added_m3: round(plan.structure_spoil_added_m3),
// 떨어진 구간끼리의 장거리 운반 — B08 내역서가 별도 운반 항목으로 세운다.
transfers: plan.transfers.map((transfer) => ({
index: transfer.index,
@@ -230,6 +237,13 @@ export function haulPlanPayload(plan: HaulPlan): Record<string, unknown> {
ea_m3: round(residual.ea_m3),
rr_m3: round(residual.rr_m3),
br_m3: round(residual.br_m3),
// ⚠ **지반을 모르는 몫**(㎥) — 갈래 합과 총량의 차이다. 지금은 **구조물 잔토**가
// 그것이다(어느 지반에서 파낸 흙인지 우리가 판정하지 않는다). 받는 쪽이 덤프 단가를
// 토사/암으로 가를 때 **이 몫만 근거가 없다**는 것을 알아야 하므로 값으로 낸다
// (2026-09-09 — 안 내면 0 으로 눅여 토사로 세기 쉽다).
ground_unknown_m3: round(
Math.max(residual.volume_m3 - (residual.ea_m3 + residual.rr_m3 + residual.br_m3), 0),
),
})),
};
}
@@ -115,6 +115,18 @@ def default_settings() -> dict[str, Any]:
# ⚠ 기본값을 두지 않는다 — 안 넣으면 물량을 안 낸다(0 으로 때우지 않음).
# 교본이 「층따기 높이·폭은 **설계도서에 명시**」라 해 설계 입력이다.
"bench_cut_depth_m": None,
# 사토장까지 운반거리(m) — 설계 입력이다. 품셈은 거리를 정하지 않는다.
# ⚠ `None` 은 「안 정함」이고, 그러면 사토 운반 줄이 **막힌 채로** 선다.
# (2026-09-09 확정 3차 ③ — 권고할 값이 없어 **물음으로 남긴 자리**다.)
"spoil_site_distance_m": None,
# 기초잡석 두께(m) — 2026-09-09 사용자 확정 3차 ② 「0.2 m」.
# ⚠ 품셈 12-25 는 ㎥당 품만 주고 두께를 정하지 않는다. 폭은 버림 폭과 같고
# (KCS 34 50 05) 두께가 이 값이다. 화면에서 바꿀 수 있다.
"rubble_base_thickness_m": 0.2,
# 구조물터파기 용수 유무 — 품셈 9-13 의 18구분 중 한 축.
# ⚠ **사용자 확정이 아니라 통상값**이다(확정 3차 ④). 화면·사유에 그 사실을
# 적어 두고 사용자가 뒤집을 수 있게 둔다.
"structure_trench_water": "육상",
"dataset_versions": {},
},
"estimation": {
@@ -32,6 +32,8 @@ export interface WallSpec {
form: string | null;
height_m: number | null;
side: string | null;
/** 기초 축 — "기초유" | "기초버림". 저장 칸과 같은 글자(터파기 그림이 이 값으로 갈린다). */
foundation: string | null;
tiers: number | null;
lift_m: number | null;
shift_m: number | null;
@@ -78,6 +80,8 @@ export function wallSpecsFrom(
form: (options.form as string) || FORM_BY_TYPE[structure.type_id] || null,
height_m: num(options.height_m),
side: (options.side as string) ?? null,
// 기초 축 — 저장 칸과 **같은 글자**. 초안 경로에서 빠지면 터파기가 안 그려진다.
foundation: (options.foundation as string) ?? null,
tiers: num(options.tiers),
lift_m: num(options.lift_m),
shift_m: num(options.shift_m),
@@ -58,11 +58,11 @@
},
{
"group": "성토",
"work_item_code": "FP-09-16",
"work_item_code": "FP-09-16-01",
"basis_unit": "㎥",
"basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L5322·L5336·L5363 세 하위 모두 「= ㎥/시간」 — 절 머리에 「(단위: …)」가 없어 마스터가 못 채운 자리(2026-09-08 원문 대조).",
"master_name": "노체",
"note": "포설(FP-09-16-01)·다짐(FP-09-16-02)로 갈리는 자리 — 내역 양식이 정해지면 내린다"
"master_name": "노체 > 노체포설",
"note": "실무 내역 셋을 다 봤더니 「노체포설·노체다짐」으로 가른 줄이 **어디에도 없다**(2026-09-08 데스크탑 보조). 영월은 「암성토 BACK-HOE(0.7㎥)」 한 줄이고 그 단가산출 안에 적사→성토→다짐 3단계가 들어가며, 울진은 「유용성토」·「사토 및 다짐공」, 봉화는 성토사면다짐만 따로다. ⇒ **성토 본체 한 줄(포설) + 성토면다짐(㎡) 따로**가 실무 양식이라 부모(FP-09-16)에서 자식 포설로 내렸다. 다짐은 그 단가 안 단계로 둔다."
},
{
"group": "성토면다짐",
@@ -109,6 +109,22 @@
"group": "되메우기",
"work_item_code": "FP-09-14-01",
"master_name": "되메우기 및 다짐 > 되메우기"
},
{
"group": "구조물터파기",
"work_item_code": "FP-09-13",
"basis_unit": "㎥",
"basis_source": "산림사업 표준품셈(고시 2025-82) 9-13 구조물터파기 — 원문 L5022~5241.",
"master_name": "구조물터파기",
"note": "⚠ 상위 코드다. 품은 18구분(토질 3 × 육상/용수 × 심도 3) 하위에만 있어 그대로는 금액이 안 선다 — 토질·용수 칸이 생기면 FP-09-13-01~18 중 하나로 내려간다. 심도는 구조물 제원(직고 + 기초 깊이)에서 이미 갈라 둔다."
},
{
"group": "기초잡석",
"work_item_code": "FP-12-25",
"basis_unit": "㎥",
"basis_source": "산림사업 표준품셈(고시 2025-82) 12-25 기초잡석 「(단위: ㎥당)」.",
"master_name": "기초잡석",
"note": "품셈은 운반·부설·다짐 품만 주고 **두께·폭을 정하지 않는다.** 폭은 버림 폭과 같고(KCS 34 50 05) 두께는 사용자 확정 3차 ②(0.2m)다 — 화면에서 바꿀 수 있다."
}
],
"haul": [
@@ -137,7 +153,9 @@
"structure": [
{
"type_id": "masonry_wet",
"secondary_axes": ["stone_kind"],
"secondary_axes": [
"stone_kind"
],
"secondary_axes_note": "⚠ 돌 종류는 **갈래 축이 하나 더**인 자리다. 품셈 13-4-2·13-4-5 [주]② 가 「본 품은 **깬돌 및 깬 잡석**의 돌쌓기 기준」이라 못 박고, 돌 종류로 갈리는 표는 **13-5 돌붙임**에 따로 있다(뒷길이 7 × 돌종류 6). 어느 쪽 공종으로 볼지는 **사용자 확정 대기** — 여기서 공종을 바꾸지 않고 **저장 원본값만 실어 보낸다**(2026-09-08 계약과 같은 방식).",
"billing_component": "돌쌓기",
"billing_note": "⚠ 품셈 밑수가 「㎡당」이라 **연장(m)으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 m 수량에 곱해 금액이 2.6배로 섰다(2026-09-08 실증: 10m × 52,938.9 = 529,389원 / 26.101㎡ × 52,938.9 = 1,381,753원). 내역 줄 수량은 이 성분(비탈면적)으로 센다. 고임돌·야면석·막자갈은 **자재 축**으로 따로 가므로 여기서 빠지지 않는다.",
@@ -149,7 +167,9 @@
},
{
"type_id": "masonry_dry",
"secondary_axes": ["stone_kind"],
"secondary_axes": [
"stone_kind"
],
"secondary_axes_note": "⚠ 돌 종류는 **갈래 축이 하나 더**인 자리다. 품셈 13-4-2·13-4-5 [주]② 가 「본 품은 **깬돌 및 깬 잡석**의 돌쌓기 기준」이라 못 박고, 돌 종류로 갈리는 표는 **13-5 돌붙임**에 따로 있다(뒷길이 7 × 돌종류 6). 어느 쪽 공종으로 볼지는 **사용자 확정 대기** — 여기서 공종을 바꾸지 않고 **저장 원본값만 실어 보낸다**(2026-09-08 계약과 같은 방식).",
"billing_component": "돌쌓기",
"billing_note": "⚠ 품셈 밑수가 「㎡당」이라 **연장(m)으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 m 수량에 곱해 금액이 2.6배로 섰다(2026-09-08 실증: 10m × 52,938.9 = 529,389원 / 26.101㎡ × 52,938.9 = 1,381,753원). 내역 줄 수량은 이 성분(비탈면적)으로 센다. 고임돌·야면석·막자갈은 **자재 축**으로 따로 가므로 여기서 빠지지 않는다.",
@@ -285,9 +305,10 @@
"code": "FP-12-25",
"name": "기초잡석",
"unit": "㎥",
"from_components": [],
"not_ready": true,
"why": "관측 원단위에 기초잡석 물량이 없음 — 울진 라이브러리 반중력식 H=2.0 항목에 그 줄이 없다"
"from_components": [
"기초잡석"
],
"why": "관측 원단위에는 기초잡석 줄이 없으나 **버림 폭이 곧 잡석다짐 폭**이라(KCS 34 50 05) 버림 물량에서 두께 비로 나온다 — 버림 0.15㎥/m ÷ 0.1 = 폭 1.5m, 두께 0.2m(사용자 확정 3차 ②) ⇒ 0.30㎥/m."
}
],
"why": "품셈 12장에 「옹벽」 공종이 없음. 실무 내역은 「반중력식옹벽 H=2.0」 한 줄이고 그 일위대가가 위 공종을 묶음.",
@@ -361,5 +382,17 @@
"흄관 밑수 두 벌": "`FP-12-11-02` 는 밑수가 「1 m」와 「1 개소」 두 벌이다(표가 둘). B09 가 `#갈래` 로 두 표를 각각 세우므로 B08 은 `variant_value` 로 어느 쪽인지 보내면 된다.",
"터파기·되메우기": "⚠ 관 부설과 터파기·되메우기가 각각 오면 **같은 굴착을 두 번 셀 수 있다**(B09 ㉡ 가드). 관 줄에는 지금 터파기를 붙이지 않는다."
}
},
"ground_aliases": {
"note": "우리 갈래 이름 ↔ 일위대가·품셈이 쓰는 다른 이름. **값을 바꾸지 않고 이름만 잇는다** — 갈래 이름을 갈아 버리면 흙깎기(FP-09-04 리핑암) 매핑이 어긋난다.",
"aliases": {
"리핑암": {
"names": [
"파쇄암",
"암절취"
],
"basis": "품셈 10-11 f 표가 「파쇄암 1/1.35」이고 10-12 [주]③ 환산계수가 「암절취 1.35」로 **같은 값**이다 ⇒ 암절취(리핑) = 파쇄암. 우리 일위대가에는 도자 운반이 「토사 1,711.9 · 파쇄암 3,911.3 · 발파암 4,708.1원/㎥」로 파쇄암 이름으로 서 있어, 이름만 못 이어 금액이 안 붙던 자리다(2026-09-08 데스크탑 보조 원문 대조)."
}
}
}
}
+26
View File
@@ -648,6 +648,32 @@ export const ui_locales_b2 = {
B08_Quantity_Side_Topsoil: ["표토제거", "Topsoil Removal"],
B08_Quantity_Side_Topsoil_Label: ["표토 두께(m)", "Topsoil thickness (m)"],
B08_Quantity_Unset_Placeholder: ["안 정함", "Not set"],
// ── 확정 3차(2026-09-09) 로 정한 값들 — **화면에 칸으로 세우고 근거를 보인다.**
// 사용자 지시: 「값을 코드에 박고 끝내지 말 것 · 대신 페이지에 남길 것」.
B08_Quantity_Side_Structure: ["구조물·사토", "Structures & Spoil"],
B08_Quantity_Side_BenchCut_Label: ["층따기 길이(m)", "Bench cut length (m)"],
B08_Quantity_Side_BenchCut_Hint: [
"면적 × 이 값 = ㎥ — 비우면 층따기 줄이 막힙니다(확정 2차 ①)",
"Area × this = ㎥ — blank keeps the bench-cut row blocked",
],
B08_Quantity_Side_Rubble_Label: ["기초잡석 두께(m)", "Rubble base thickness (m)"],
B08_Quantity_Side_Rubble_Hint: [
"폭은 버림 폭과 같음(KCS 34 50 05) · 두께 0.2m 는 사용자 확정 3차 ② — 품셈 12-25 는 두께를 정하지 않음",
"Width equals the blinding width (KCS 34 50 05); 0.2 m thickness is a user decision — the standard gives none",
],
B08_Quantity_Side_SpoilDistance_Label: ["사토장까지 거리(m)", "Distance to spoil site (m)"],
B08_Quantity_Side_SpoilDistance_Hint: [
"비어 있으면 사토 운반 줄이 막힙니다 — 품셈이 정하는 값이 아니라 현장값입니다",
"Blank keeps the spoil haul row blocked — this is a site value, not from the standard",
],
B08_Quantity_Side_Water_Label: ["구조물터파기 용수", "Structure trench water"],
B08_Quantity_Water_Dry: ["육상(용수 없음)", "Dry"],
B08_Quantity_Water_Wet: ["용수", "Wet"],
B08_Quantity_Water_Unset: ["안 정함(줄이 막힘)", "Not set (row blocked)"],
B08_Quantity_Side_Water_Hint: [
"⚠ 「육상」은 통상값이고 사용자 확정이 아닙니다(확정 3차 ④) — 품셈 9-13 의 18구분이 이 값으로 갈립니다",
"⚠ “Dry” is a customary default, not a user decision — it selects one of the 18 sub-items",
],
B08_Quantity_Side_Placing: ["콘크리트 타설", "Concrete Placing"],
B08_Quantity_Side_Placing_Label: ["타설 방식", "Method"],
B08_Quantity_Placing_Unset: ["안 정함(기본값 사용)", "Not set (default)"],