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

This commit is contained in:
2026-09-08 22:26:47 +09:00
4 changed files with 222 additions and 2 deletions
@@ -43,6 +43,7 @@ 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_Trench import structure_earthwork_rows
# ⚠ 파일만 갈랐고 **계약은 그대로다** — 종전에 이 이름으로 가져다 쓰던 곳이 그대로 돌게
# 여기서 다시 내보낸다(2026-09-08 분리).
@@ -124,6 +125,12 @@ def build_handoff(
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)
work_items.extend(rows)
unmatched.extend(misses)
# 준비공·사방공 — **못 내는 줄도 사유와 함께** 보낸다(빼면 빠진 줄이 안 보인다).
# B군 종단배수 — 겹침을 합친 연장으로 종류별 한 줄(위 `_length_rows` 주석).
@@ -145,6 +152,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,181 @@
"""구조물 터파기·되메우기·잔토 인계 줄 (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,
WorkItemMapping,
)
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 = "구조물터파기"
BACKFILL_GROUP = "되메우기"
SPOIL_GROUP = "잔토처리"
TRENCH_BLOCKED_REASON = (
"토질(토사·암절취·발파암)과 용수 유무가 저장 제원에 없어 품셈 9-13 의 18구분 중 어느 칸인지"
" 못 고름 — 심도만 구조물 제원(직고 + 기초 깊이)에서 갈라 둠"
)
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 structure_earthwork_rows(
unit_quantity_table: dict[str, Any], mapping: WorkItemMapping
) -> tuple[list[dict[str, Any]], list[str]]:
"""구조물이 낸 터파기·되메우기·잔토를 **공종 축**으로 올린다."""
trench: dict[str, float] = {}
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))
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 == "터파기":
trench[band] = trench.get(band, 0.0) + amount
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)
for _, band in (*DEPTH_BANDS, (0.0, DEPTH_OVER)):
amount = trench.get(band)
if not amount:
continue
rows.append(
_row(
work_item_code=code,
name=TRENCH_GROUP,
spec=f"심도 {band}",
spec_detail=f"구조물 전개 합 · 심도 {band}",
quantity=amount,
blocked_kind=BLOCKED_INPUT_MISSING,
blocked_reason=TRENCH_BLOCKED_REASON,
in_bill=False,
in_bill_reason=TRENCH_BLOCKED_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
@@ -109,6 +109,14 @@
"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 중 하나로 내려간다. 심도는 구조물 제원(직고 + 기초 깊이)에서 이미 갈라 둔다."
}
],
"haul": [
@@ -137,7 +145,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 +159,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원). 내역 줄 수량은 이 성분(비탈면적)으로 센다. 고임돌·야면석·막자갈은 **자재 축**으로 따로 가므로 여기서 빠지지 않는다.",
@@ -361,5 +373,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 데스크탑 보조 원문 대조)."
}
}
}
}