diff --git a/B06_Section/B06_Section_Router_HaulPlan.py b/B06_Section/B06_Section_Router_HaulPlan.py index 77c26186..2fc2dc7c 100644 --- a/B06_Section/B06_Section_Router_HaulPlan.py +++ b/B06_Section/B06_Section_Router_HaulPlan.py @@ -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) diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 38e54977..41ce275e 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -52,7 +52,10 @@ async function withDraftWalls( projectId: string, ): Promise { 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 diff --git a/B06_Section/B06_Section_Server_Calc_Node.ts b/B06_Section/B06_Section_Server_Calc_Node.ts index 1dc529e6..faedf92b 100644 --- a/B06_Section/B06_Section_Server_Calc_Node.ts +++ b/B06_Section/B06_Section_Server_Calc_Node.ts @@ -39,6 +39,17 @@ interface ServerCalcInput { haul_equipment_limits?: Parameters[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 diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index 8e43e6f1..001dbaf3 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -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): diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 27c1484b..e4ccee55 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -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[], diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index b5c18b79..061fd070 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -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; diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index e6d72e12..a8fdf833 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -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( diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py index 19f2c66c..d66196fe 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py @@ -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 {}, ) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py new file mode 100644 index 00000000..189c9790 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Spoil.py @@ -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, + } diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py new file mode 100644 index 00000000..ab565818 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Trench.py @@ -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 diff --git a/B08_Quantity/B08_Quantity_Engine_HaulInputs.py b/B08_Quantity/B08_Quantity_Engine_HaulInputs.py new file mode 100644 index 00000000..c1c2894d --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_HaulInputs.py @@ -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"]), + } diff --git a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py index c8283641..7f9a5810 100644 --- a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py @@ -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) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index d9e20814..c63af84c 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -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": [ { diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 442ae047..0251e050 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -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") diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index 339fde83..d9e2cd49 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -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 diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index be3184cc..49a8b023 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -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 { diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts index fb8fe14d..69be9c9f 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts @@ -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); } `; /** 스타일을 한 번만 넣는다 — 페이지를 다시 그려도 중복되지 않는다. */ diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index affaff1c..a86d81f4 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -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 = { 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; 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 { rock_methods: { ...((stored.rock_methods ?? {}) as Record) }, 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) }, dirty: false, }; diff --git a/common_util/common_util_mass_haul_balance.ts b/common_util/common_util_mass_haul_balance.ts index cce5d269..5bc1f8c0 100644 --- a/common_util/common_util_mass_haul_balance.ts +++ b/common_util/common_util_mass_haul_balance.ts @@ -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, diff --git a/common_util/common_util_mass_haul_settle.ts b/common_util/common_util_mass_haul_settle.ts index 8c3923bf..5f99367f 100644 --- a/common_util/common_util_mass_haul_settle.ts +++ b/common_util/common_util_mass_haul_settle.ts @@ -179,6 +179,13 @@ export function haulPlanPayload(plan: HaulPlan): Record { 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 { 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), + ), })), }; } diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 793882ac..880b2537 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -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": { diff --git a/common_util/common_util_structure_walls.ts b/common_util/common_util_structure_walls.ts index 1047ed46..5cd7c2e8 100644 --- a/common_util/common_util_structure_walls.ts +++ b/common_util/common_util_structure_walls.ts @@ -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), diff --git a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json index bb8f95fe..a1b4d3ff 100644 --- a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -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 데스크탑 보조 원문 대조)." + } + } } } diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index aac5b82c..d7ea2902 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -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)"],