Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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); }
|
||||
`;
|
||||
|
||||
/** 스타일을 한 번만 넣는다 — 페이지를 다시 그려도 중복되지 않는다. */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 데스크탑 보조 원문 대조)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"],
|
||||
|
||||
Reference in New Issue
Block a user