Merge remote-tracking branch 'origin/main_desktop_1' into sub_laptop_1
This commit is contained in:
@@ -85,3 +85,49 @@ def _split_cut_areas(
|
|||||||
soil_area += (min(max(d_a, 0.0), t0) + min(max(d_b, 0.0), t0)) / 2.0 * span
|
soil_area += (min(max(d_a, 0.0), t0) + min(max(d_b, 0.0), t0)) / 2.0 * span
|
||||||
rock_area += (max(d_a - t0, 0.0) + max(d_b - t0, 0.0)) / 2.0 * span
|
rock_area += (max(d_a - t0, 0.0) + max(d_b - t0, 0.0)) / 2.0 * span
|
||||||
return soil_area, rock_area
|
return soil_area, rock_area
|
||||||
|
|
||||||
|
|
||||||
|
# 층따기 대상 판정 기울기 — 원지반 횡단기울기 1:4(=25%)보다 급한 곳에만 한다.
|
||||||
|
# 근거: 임도설치 및 관리 등에 관한 규정 별표2 · 임도기술교본 6장 4절 「경사지의 층따기에
|
||||||
|
# 있어 그 경사가 1:4보다 급한 경사를 가진 지반 위에 성토를 하는 경우 … 층따기를 설치」.
|
||||||
|
# 지식DB `01_임도/02_상세설계/성토_비탈면.md` §4 [구현] 「원지반 횡단경사 > 25% 구간의 성토부」.
|
||||||
|
_BENCH_CUT_MIN_GROUND_SLOPE = 0.25
|
||||||
|
|
||||||
|
|
||||||
|
def _bench_cut_length(offsets: list[float], grounds: list[float], diffs: list[float]) -> float:
|
||||||
|
"""층따기 밑수 — **성토부 아래 원지반 표면의 경사길이(m)**.
|
||||||
|
|
||||||
|
무엇을 재나
|
||||||
|
성토(diff<0)가 원지반에 얹히는 구간에서, 원지반 횡단기울기가 1:4 보다 급한
|
||||||
|
조각만 골라 **지표면을 따라간 길이**를 더한다. 수평 폭이 아니라 빗변이다 —
|
||||||
|
층따기는 그 경사면을 계단으로 깎는 일이라 대상 면이 곧 지표면이다.
|
||||||
|
|
||||||
|
왜 성토면이 아니라 원지반인가
|
||||||
|
층따기는 **원지반 표면**에 하는 것이다(교본 6장 4절). 성토 비탈면 길이로 재면
|
||||||
|
대상이 아닌 면을 세는 것이 된다.
|
||||||
|
|
||||||
|
단위
|
||||||
|
여기서 나오는 것은 **길이(m)** 다. 면적(㎡)은 측점 사이를 평균단면적법으로 이어
|
||||||
|
B08 이 낸다 — 사면 4계열과 같은 방식이라 계산을 두 벌로 짜지 않는다.
|
||||||
|
(2026-09-09 사용자 확정: 층따기 단위는 ㎡.)
|
||||||
|
"""
|
||||||
|
total = 0.0
|
||||||
|
for index in range(1, len(offsets)):
|
||||||
|
run = offsets[index] - offsets[index - 1]
|
||||||
|
if run <= 0:
|
||||||
|
continue
|
||||||
|
d0, d1 = diffs[index - 1], diffs[index]
|
||||||
|
# 성토 조각만 — 부호가 바뀌면 영교점까지만 성토다.
|
||||||
|
if d0 >= 0 and d1 >= 0:
|
||||||
|
continue
|
||||||
|
share = 1.0
|
||||||
|
if d0 * d1 < 0:
|
||||||
|
zero_ratio = d0 / (d0 - d1)
|
||||||
|
share = (1.0 - zero_ratio) if d0 > 0 else zero_ratio
|
||||||
|
if share <= 0:
|
||||||
|
continue
|
||||||
|
rise = grounds[index] - grounds[index - 1]
|
||||||
|
if abs(rise) / run < _BENCH_CUT_MIN_GROUND_SLOPE:
|
||||||
|
continue
|
||||||
|
total += ((run**2 + rise**2) ** 0.5) * share
|
||||||
|
return total
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from collections.abc import Callable
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from B06_Section.B06_Section_Engine_Areas import (
|
from B06_Section.B06_Section_Engine_Areas import (
|
||||||
|
_bench_cut_length,
|
||||||
_split_cut_areas,
|
_split_cut_areas,
|
||||||
_trapezoid_areas,
|
_trapezoid_areas,
|
||||||
)
|
)
|
||||||
@@ -684,17 +685,22 @@ def compute_cross_design(
|
|||||||
merged = sorted(set(round(offset, 6) for offset in merged))
|
merged = sorted(set(round(offset, 6) for offset in merged))
|
||||||
|
|
||||||
offsets: list[float] = []
|
offsets: list[float] = []
|
||||||
|
grounds: list[float] = []
|
||||||
diffs: list[float] = []
|
diffs: list[float] = []
|
||||||
design_line: list[dict[str, float]] = []
|
design_line: list[dict[str, float]] = []
|
||||||
for offset_m in merged:
|
for offset_m in merged:
|
||||||
ground_m = ground_at(offset_m)
|
ground_m = ground_at(offset_m)
|
||||||
design_z = geometry.design_z(offset_m, ground_m)
|
design_z = geometry.design_z(offset_m, ground_m)
|
||||||
offsets.append(offset_m)
|
offsets.append(offset_m)
|
||||||
|
grounds.append(ground_m)
|
||||||
diffs.append(ground_m - design_z)
|
diffs.append(ground_m - design_z)
|
||||||
design_line.append({"offset_m": round(offset_m, 4), "elevation_m": round(design_z, 4)})
|
design_line.append({"offset_m": round(offset_m, 4), "elevation_m": round(design_z, 4)})
|
||||||
|
|
||||||
# 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음 — 이중계상 방지).
|
# 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음 — 이중계상 방지).
|
||||||
cut_area, fill_area = _trapezoid_areas(offsets, diffs)
|
cut_area, fill_area = _trapezoid_areas(offsets, diffs)
|
||||||
|
# 층따기 밑수(길이 m) — 성토부 아래 원지반이 1:4 보다 급한 구간의 지표면 길이.
|
||||||
|
# 면적(㎡)은 측점 사이를 평균단면적법으로 이어 B08 이 낸다(2026-09-09 사용자 확정).
|
||||||
|
bench_cut_length = _bench_cut_length(offsets, grounds, diffs)
|
||||||
fill_ground_slope = geometry.fill_ground_slope()
|
fill_ground_slope = geometry.fill_ground_slope()
|
||||||
# 사면이 샘플 범위 끝에서도 원지반과 만나지 않으면 면적이 거기서 잘린다 — 그만큼
|
# 사면이 샘플 범위 끝에서도 원지반과 만나지 않으면 면적이 거기서 잘린다 — 그만큼
|
||||||
# 절·성토량이 실제와 다르고 유토곡선도 그 값을 그대로 쌓는다. 영원히 안 만나는
|
# 절·성토량이 실제와 다르고 유토곡선도 그 값을 그대로 쌓는다. 영원히 안 만나는
|
||||||
@@ -807,6 +813,10 @@ def compute_cross_design(
|
|||||||
"cut_rock_area_m2": round(cut_rock_area, 4),
|
"cut_rock_area_m2": round(cut_rock_area, 4),
|
||||||
"cut_rock_kind": cut_rock_kind,
|
"cut_rock_kind": cut_rock_kind,
|
||||||
"fill_area_m2": round(fill_area, 4),
|
"fill_area_m2": round(fill_area, 4),
|
||||||
|
# 층따기 밑수 — 성토부 아래 원지반(1:4 보다 급한 구간)의 지표면 길이(m).
|
||||||
|
# B08 이 측점 사이를 이어 ㎡ 로 만든다. 여기서 ㎥ 로 바꾸지 않는다 —
|
||||||
|
# 단의 높이·폭이 설계도서 값이라 지어낼 수 없다.
|
||||||
|
"bench_cut_length_m": round(bench_cut_length, 4),
|
||||||
# 사면이 샘플 범위 끝까지 원지반을 못 만나 면적이 잘린 측점 — 경고 표기용.
|
# 사면이 샘플 범위 끝까지 원지반을 못 만나 면적이 잘린 측점 — 경고 표기용.
|
||||||
"slope_unclosed": slope_unclosed,
|
"slope_unclosed": slope_unclosed,
|
||||||
# 성토측 자연 지반 경사(rise/run) — 자연방토 판정 입력. 성토측이 없으면 None.
|
# 성토측 자연 지반 경사(rise/run) — 자연방토 판정 입력. 성토측이 없으면 None.
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ interface ServerCalcInput {
|
|||||||
earthwork_conversion?: Parameters<typeof computeMassHaul>[1];
|
earthwork_conversion?: Parameters<typeof computeMassHaul>[1];
|
||||||
natural_spoil_min_ground_slope?: number | null;
|
natural_spoil_min_ground_slope?: number | null;
|
||||||
haul_equipment_limits?: Parameters<typeof computeHaulPlan>[1];
|
haul_equipment_limits?: Parameters<typeof computeHaulPlan>[1];
|
||||||
|
/** 채집석 공제(㎥, 양수) — B08 이 낸다. `null`/없음은 「아직 안 옴」이다. */
|
||||||
|
collected_stone_deduction_m3?: number | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +54,9 @@ const input = JSON.parse(readFileSync(inputPath, "utf8")) as ServerCalcInput;
|
|||||||
if (input.haul_plan_for) {
|
if (input.haul_plan_for) {
|
||||||
// **화면이 쓰는 꼴 그대로** 내보낸다(직렬화 형태 `haulPlanPayload` 가 아니다) — 그래야
|
// **화면이 쓰는 꼴 그대로** 내보낸다(직렬화 형태 `haulPlanPayload` 가 아니다) — 그래야
|
||||||
// 그리기 코드가 손대지 않고 그대로 받는다. 전부 숫자·문자열이라 JSON 으로 오간다.
|
// 그리기 코드가 손대지 않고 그대로 받는다. 전부 숫자·문자열이라 JSON 으로 오간다.
|
||||||
const plan = computeHaulPlan(input.haul_plan_for, input.context?.haul_equipment_limits);
|
const plan = computeHaulPlan(input.haul_plan_for, input.context?.haul_equipment_limits, {
|
||||||
|
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
|
||||||
|
});
|
||||||
writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null }));
|
writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null }));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
@@ -73,7 +77,11 @@ const result = conversion
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
// 배분은 **서버만** 만든다 — 그래야 그 코드가 브라우저 번들에서 빠진다(2026-09-06).
|
// 배분은 **서버만** 만든다 — 그래야 그 코드가 브라우저 번들에서 빠진다(2026-09-06).
|
||||||
const plan = result ? computeHaulPlan(result, input.context?.haul_equipment_limits) : null;
|
const plan = result
|
||||||
|
? computeHaulPlan(result, input.context?.haul_equipment_limits, {
|
||||||
|
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
const massHaul = result
|
const massHaul = result
|
||||||
? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null)
|
? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null)
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -63,8 +63,18 @@ _AREA_KEYS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _mass_haul_context() -> dict[str, Any]:
|
def _mass_haul_context(collected_stone_deduction_m3: float | None = None) -> dict[str, Any]:
|
||||||
"""유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다."""
|
"""유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다.
|
||||||
|
|
||||||
|
⚠ 채집석 공제(`collected_stone_deduction_m3`)만 상수가 아니라 **B08 이 내는 값**이다.
|
||||||
|
`None` 은 「아직 안 옴」이고 `0` 은 「공제 없음」이라 **서로 다르다** — 값이 안 온 것을
|
||||||
|
공제 0 으로 읽으면 조용히 넘어간다(2026-09-09 네 창 합의).
|
||||||
|
|
||||||
|
채집석 공제는 사토에서 한 번만 뺀다.
|
||||||
|
B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며,
|
||||||
|
빼는 자리는 유토곡선의 사토뿐이다 —
|
||||||
|
실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다.
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
"earthwork_conversion": EARTHWORK_CONVERSION_FACTORS,
|
"earthwork_conversion": EARTHWORK_CONVERSION_FACTORS,
|
||||||
"natural_spoil_min_ground_slope": NATURAL_SPOIL_MIN_GROUND_SLOPE,
|
"natural_spoil_min_ground_slope": NATURAL_SPOIL_MIN_GROUND_SLOPE,
|
||||||
@@ -72,6 +82,9 @@ def _mass_haul_context() -> dict[str, Any]:
|
|||||||
{"key": key, "max_distance_m": limit}
|
{"key": key, "max_distance_m": limit}
|
||||||
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
|
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
|
||||||
],
|
],
|
||||||
|
# ⚠ B08 이 아직 이 값을 내지 않는다(2026-09-09) — 그때까지 `None`(아직 안 옴)이다.
|
||||||
|
# 값을 내기 시작하면 여기에 실어 주기만 하면 통로가 이어진다.
|
||||||
|
"collected_stone_deduction_m3": collected_stone_deduction_m3,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,11 @@ def build_handoff(
|
|||||||
result["basis_unit_warnings"] = verify_unit_matches_basis(
|
result["basis_unit_warnings"] = verify_unit_matches_basis(
|
||||||
work_items, extra=table.declared_units()
|
work_items, extra=table.declared_units()
|
||||||
)
|
)
|
||||||
|
# ⚠ 채집석 공제 — **양수 ㎥ 로 넘기기만** 한다. 빼는 자리는 유토곡선의 사토뿐이다
|
||||||
|
# (2026-09-09 세 창 확정 · 부호를 넘기면 두 번 뒤집힌다).
|
||||||
|
result["collected_stone_deduction_m3"] = float(
|
||||||
|
(unit_quantity_table or {}).get("collected_stone_deduction_m3") or 0.0
|
||||||
|
)
|
||||||
result["placing_notes"] = placing_notes
|
result["placing_notes"] = placing_notes
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -406,6 +406,10 @@ def _structure_rows(
|
|||||||
#: (넣으려면 돌쌓기 일위대가에 타설 품이 있는지부터 확인할 것 — B09 ㉢ 과 같은 자리.)
|
#: (넣으려면 돌쌓기 일위대가에 타설 품이 있는지부터 확인할 것 — B09 ㉢ 과 같은 자리.)
|
||||||
PLACING_TARGET_NAMES = frozenset({"콘크리트", "버림콘크리트", "레미콘"})
|
PLACING_TARGET_NAMES = frozenset({"콘크리트", "버림콘크리트", "레미콘"})
|
||||||
|
|
||||||
|
#: 버림 타설 줄의 갈래 이름 — **실무 내역 표기 그대로**(「레미콘타설(장비) 무근,버림」).
|
||||||
|
#: ⚠ 버림은 늘 무근이라 구조물 종류(무근/철근)를 따라가지 않는다.
|
||||||
|
BLINDING_PLACING_KIND = "무근,버림"
|
||||||
|
|
||||||
|
|
||||||
def _placing_rows(
|
def _placing_rows(
|
||||||
unit_quantity_table: dict[str, Any],
|
unit_quantity_table: dict[str, Any],
|
||||||
@@ -435,15 +439,18 @@ def _placing_rows(
|
|||||||
# ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다.
|
# ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다.
|
||||||
if mapping.composite_for(str(structure.get("type_id") or "")):
|
if mapping.composite_for(str(structure.get("type_id") or "")):
|
||||||
continue
|
continue
|
||||||
volume = sum(
|
# ⚠ 버림은 **따로 센다** — 실무 내역이 「레미콘타설(장비) **무근,버림**」으로 갈라
|
||||||
float(component.get("amount") or 0.0)
|
# 적는다(봉화 제50호표, 2026-09-09 데스크탑 보조 확인). 같은 공종·같은 단가라
|
||||||
for component in structure.get("components") or []
|
# 금액은 안 움직이고 **이름만 맞추는 것**이다.
|
||||||
if str(component.get("name") or "").strip() in PLACING_TARGET_NAMES
|
for component in structure.get("components") or []:
|
||||||
and component.get("unit") == "㎥"
|
name = str(component.get("name") or "").strip()
|
||||||
)
|
if name not in PLACING_TARGET_NAMES or component.get("unit") != "㎥":
|
||||||
if volume <= 0:
|
continue
|
||||||
continue
|
volume = float(component.get("amount") or 0.0)
|
||||||
buckets[structure_kind(structure)] = buckets.get(structure_kind(structure), 0.0) + volume
|
if volume <= 0:
|
||||||
|
continue
|
||||||
|
kind = BLINDING_PLACING_KIND if name == "버림콘크리트" else structure_kind(structure)
|
||||||
|
buckets[kind] = buckets.get(kind, 0.0) + volume
|
||||||
rows = [
|
rows = [
|
||||||
{
|
{
|
||||||
"work_item_code": code,
|
"work_item_code": code,
|
||||||
|
|||||||
@@ -246,16 +246,117 @@ def erosion_rows(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
#: 부대시설·가설공사 — **법이 요구하는데 우리가 안 내던 다섯 줄**(2026-09-09 사용자 확정 ⑬).
|
||||||
|
#: `key` 는 설정의 `ancillary_counts` 칸 이름, `code` 는 품셈 공종(없으면 `None`).
|
||||||
|
#: ⚠ 다섯 중 **품셈에 공종이 있는 것은 가설창고 하나뿐**이다(마스터 전수 확인).
|
||||||
|
#: 나머지 넷은 **금액이 못 선다** — 줄은 세우되 「공종 자체가 품셈에 없음」이라고 적는다.
|
||||||
|
#: 「아직 안 만든 것」과 갈라 적어야 다음에 할 일이 달라진다.
|
||||||
|
ANCILLARY_ITEMS: tuple[dict[str, Any], ...] = (
|
||||||
|
{
|
||||||
|
"key": "national_point_sign",
|
||||||
|
"item": "국가지점번호판",
|
||||||
|
"unit": "개소",
|
||||||
|
"code": None,
|
||||||
|
"legal": True,
|
||||||
|
"why": (
|
||||||
|
"⚠ 법정 의무 — 임도규정 제26조제5항 「국가지점번호판을 제작하여 500미터 마다 "
|
||||||
|
"설치·관리하되, 필요시 거리를 조정할 수 있으며」. ⚠ 기점 포함·종점 잔여·갈림길 "
|
||||||
|
"중복을 원문이 정하지 않아 **연장÷500 을 산식으로 쓰지 않는다** — 개소를 넣으면 섬"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "guide_sign",
|
||||||
|
"item": "임도 안내판",
|
||||||
|
"unit": "개소",
|
||||||
|
"code": None,
|
||||||
|
"legal": True,
|
||||||
|
"why": (
|
||||||
|
"⚠ 법정 의무 — 임도규정 제26조제6항 「임도의 시점 및 종점에 안내판…을 설치하여야 "
|
||||||
|
"한다」. 노선을 이어 가면 최초 시점·최종 종점에 둘 수 있어 **개소는 설계 판단**임"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "gate",
|
||||||
|
"item": "차단기",
|
||||||
|
"unit": "개소",
|
||||||
|
"code": None,
|
||||||
|
"legal": False,
|
||||||
|
"why": "임도기술교본 11장(실무 참고) — 법령·행정규칙에는 없음. 설치 개소는 설계 판단",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "site_container",
|
||||||
|
"item": "가설창고(컨테이너)",
|
||||||
|
"unit": "개소",
|
||||||
|
"code": "FP-11-01",
|
||||||
|
"legal": False,
|
||||||
|
"why": "품셈 11-1 콘테이너형 가설건축물 — ⚠ 개소는 현장 조건이라 설계가 정함",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "flood_supplies",
|
||||||
|
"item": "수방대책 자재",
|
||||||
|
"unit": "식",
|
||||||
|
"code": None,
|
||||||
|
"legal": False,
|
||||||
|
"why": (
|
||||||
|
"임도기술교본 11장 — 비닐·말뚝·마대·삽 등을 현장 입구에 비치. "
|
||||||
|
"품목·수량이 원문에 없어 **한 벌(식)로 받는다**"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
#: 품셈에 그 이름의 공종이 아예 없는 줄에 적는 사유. 「아직 안 만든 것」과 갈라 쓴다.
|
||||||
|
REASON_NO_WORK_ITEM = "품셈에 그 이름의 공종이 없음 — 금액은 별도 단가로만 설 수 있음"
|
||||||
|
|
||||||
|
|
||||||
|
def ancillary_rows(counts: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||||
|
"""부대시설·가설공사 줄. **개소를 안 넣어도 줄은 선다** — 빠진 것이 보이게.
|
||||||
|
|
||||||
|
⚠ 개소를 **지어내지 않는다**(확정 ⑬). 설정 `ancillary_counts` 에 넣은 값만 쓴다.
|
||||||
|
"""
|
||||||
|
given = {str(key): value for key, value in (counts or {}).items()}
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for spec in ANCILLARY_ITEMS:
|
||||||
|
raw = given.get(spec["key"])
|
||||||
|
try:
|
||||||
|
amount = float(raw) if raw is not None and str(raw).strip() != "" else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
amount = None
|
||||||
|
reasons = [spec["why"]]
|
||||||
|
if spec["code"] is None:
|
||||||
|
reasons.append(REASON_NO_WORK_ITEM)
|
||||||
|
if amount is None:
|
||||||
|
reasons.append("개소가 아직 입력되지 않았습니다 — 넣으면 물량이 섭니다")
|
||||||
|
status = STATUS_PENDING
|
||||||
|
else:
|
||||||
|
status = STATUS_READY if spec["code"] else STATUS_PENDING
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"group": "부대시설",
|
||||||
|
"item": spec["item"],
|
||||||
|
"unit": spec["unit"],
|
||||||
|
"amount": amount,
|
||||||
|
"status": status,
|
||||||
|
"work_item_code": spec["code"],
|
||||||
|
"legal_required": bool(spec["legal"]),
|
||||||
|
"reason": " · ".join(reasons),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def build_table(
|
def build_table(
|
||||||
slope_totals: dict[str, float] | None = None,
|
slope_totals: dict[str, float] | None = None,
|
||||||
structures: Iterable[dict[str, Any]] = (),
|
structures: Iterable[dict[str, Any]] = (),
|
||||||
slope_rows: Iterable[dict[str, Any]] = (),
|
slope_rows: Iterable[dict[str, Any]] = (),
|
||||||
topsoil_thickness_m: float | None = None,
|
topsoil_thickness_m: float | None = None,
|
||||||
names: dict[str, str] | None = None,
|
names: dict[str, str] | None = None,
|
||||||
|
ancillary_counts: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**"""
|
"""화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**"""
|
||||||
rows = preparation_rows(slope_totals, slope_rows, topsoil_thickness_m) + erosion_rows(
|
rows = (
|
||||||
structures, names
|
preparation_rows(slope_totals, slope_rows, topsoil_thickness_m)
|
||||||
|
+ erosion_rows(structures, names)
|
||||||
|
+ ancillary_rows(ancillary_counts)
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"columns": ["구분", "공종", "단위", "수량", "상태", "사유"],
|
"columns": ["구분", "공종", "단위", "수량", "상태", "사유"],
|
||||||
|
|||||||
@@ -86,8 +86,11 @@ def _length_of(slope: StationSlope, series: str, face: str) -> float:
|
|||||||
법면보호공은 면고르기를 참조한다 — 같은 사면길이를 쓴다. 끊고 싶으면 이 함수만 고친다.
|
법면보호공은 면고르기를 참조한다 — 같은 사면길이를 쓴다. 끊고 싶으면 이 함수만 고친다.
|
||||||
층따기는 성토면만 대상이다.
|
층따기는 성토면만 대상이다.
|
||||||
"""
|
"""
|
||||||
if series == "bench_cut" and face != "fill":
|
if series == "bench_cut":
|
||||||
return 0.0
|
# ⚠ 층따기는 **원지반 표면**을 깎는 일이라 밑수가 성토 비탈면이 아니다
|
||||||
|
# (교본 6장 4절). B06 설계가 측점마다 내는 값을 그대로 쓴다.
|
||||||
|
# 없으면 0 — 성토 사면길이로 대신 채우면 **다른 면을 세게 된다**(2026-09-09 정정).
|
||||||
|
return slope.bench_cut_length_m if face == "fill" else 0.0
|
||||||
return slope.fill_length_m if face == "fill" else slope.cut_length_m
|
return slope.fill_length_m if face == "fill" else slope.cut_length_m
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ class StationSlope:
|
|||||||
chainage_m: float
|
chainage_m: float
|
||||||
cut_length_m: float = 0.0
|
cut_length_m: float = 0.0
|
||||||
fill_length_m: float = 0.0
|
fill_length_m: float = 0.0
|
||||||
|
#: 층따기 밑수 — **원지반 표면**의 경사길이(m). B06 설계가 측점마다 낸다
|
||||||
|
#: (`design.bench_cut_length_m`, 2026-09-09 랩탑 메인).
|
||||||
|
#: ⚠ **성토 비탈면 길이와 다른 면이다** — 층따기는 성토부 **아래 원지반**을 계단으로
|
||||||
|
#: 깎는 일이라(교본 6장 4절) 비탈면이 아니라 지표면을 따라간다. 앞서 성토 사면길이를
|
||||||
|
#: 밑수로 쓰고 있었는데 **면이 달랐다.**
|
||||||
|
bench_cut_length_m: float = 0.0
|
||||||
berm_width_m: float = 0.0
|
berm_width_m: float = 0.0
|
||||||
# 성토고(m) — 성토 사면 조각들의 **수직 낙차 합**. 노면 끝에서 원지반까지 내려간 높이다.
|
# 성토고(m) — 성토 사면 조각들의 **수직 낙차 합**. 노면 끝에서 원지반까지 내려간 높이다.
|
||||||
# ⚠ 좌우가 다르면 **큰 쪽**을 쓴다. 「중심점 성토고 5m 이상」(품셈 11-3 [주]①) 판정은
|
# ⚠ 좌우가 다르면 **큰 쪽**을 쓴다. 「중심점 성토고 5m 이상」(품셈 11-3 [주]①) 판정은
|
||||||
@@ -216,6 +222,8 @@ def station_slope(chainage_m: float, design: dict[str, Any]) -> StationSlope:
|
|||||||
chainage_m=float(chainage_m),
|
chainage_m=float(chainage_m),
|
||||||
cut_length_m=sum(s.length_m for s in segments if s.role == "cut"),
|
cut_length_m=sum(s.length_m for s in segments if s.role == "cut"),
|
||||||
fill_length_m=sum(s.length_m for s in segments if s.role == "fill"),
|
fill_length_m=sum(s.length_m for s in segments if s.role == "fill"),
|
||||||
|
# ⚠ 없는 측점은 0 이다 — 성토 사면길이로 **대신 채우지 않는다**(면이 다름).
|
||||||
|
bench_cut_length_m=_num(design.get("bench_cut_length_m")) or 0.0,
|
||||||
fill_height_m=max(fill_by_side.values(), default=0.0),
|
fill_height_m=max(fill_by_side.values(), default=0.0),
|
||||||
berm_width_m=_num(berm.get("width_m")) or 0.0,
|
berm_width_m=_num(berm.get("width_m")) or 0.0,
|
||||||
segments=tuple(segments),
|
segments=tuple(segments),
|
||||||
|
|||||||
@@ -157,6 +157,39 @@ STONE_MASONRY = {
|
|||||||
# `earthwork` = 토공 대분류로 합산(울진 토적집계 D12~D14 실증)
|
# `earthwork` = 토공 대분류로 합산(울진 토적집계 D12~D14 실증)
|
||||||
# `material` = 자재총괄로 감(할증은 거기서 한 번만)
|
# `material` = 자재총괄로 감(할증은 거기서 한 번만)
|
||||||
# `unit_price` = 일위대가 재료비 구성으로 감(B09 가 배합을 분해)
|
# `unit_price` = 일위대가 재료비 구성으로 감(B09 가 배합을 분해)
|
||||||
|
#: 버림 콘크리트 — **빠뜨리고 있던 줄**이다(2026-09-09 사용자 확정 ⑭).
|
||||||
|
#: 두께 근거: KDS 44 90 00 도로암거구조설계기준 「기초시공시 기초지반 다짐을 시행하고
|
||||||
|
#: 구조물 시공이 원활하도록 **100 mm 두께의 버림콘크리트**를 타설하도록 한다」.
|
||||||
|
#: 폭 근거: KCS 34 50 05 「버림 콘크리트의 두께는 설계도서에 따르며, **폭은 잡석다짐의
|
||||||
|
#: 폭과 동일**하게 한다」.
|
||||||
|
#: ⚠ 우리에게 **잡석다짐 폭이 아직 없다**(기초잡석은 품셈에 공종만 있고 두께·폭이 없음).
|
||||||
|
#: 그래서 **터파기 폭(평균두께 + 여유 0.2m)을 잠정으로** 쓰고 근거 문구에 그 사실을 적는다.
|
||||||
|
#: 잡석다짐 폭이 정해지면 이 한 줄만 바꾸면 된다.
|
||||||
|
#: 돌을 **사 오나 캐나** — 기본은 「캔다」(2026-09-09 사용자 확정 ②).
|
||||||
|
#: 법이 그쪽을 권한다 — 별표2 「석축 등에 필요한 야면석 등은 **가급적 현장에서
|
||||||
|
#: 채취·사용**하도록 운반거리를 조사한다」. 실무 견적 다섯 권에도 야면석 **구입 단가가
|
||||||
|
#: 0건**이고 울진 일위대가는 「구입/채집」 두 벌을 갖고 있다.
|
||||||
|
#: ⚠ 구조물마다 바꿀 수 있다 — 저장 제원에 「구입」이라 적힌 구조물만 공제에서 빠진다.
|
||||||
|
STONE_SUPPLY_KEYS = ("stone_supply", "stone_source")
|
||||||
|
STONE_SUPPLY_PURCHASED = {"구입", "구입품", "사서", "purchase", "purchased", "buy"}
|
||||||
|
|
||||||
|
#: ⚠⚠ **채집석 공제는 사토에서 한 번만 뺀다** (2026-09-09 세 창 확정 · 랩탑 메인의 통로에도
|
||||||
|
#: 같은 문장이 박혀 있다 — 두 곳이 같은 말이라야 나중에 누가 봐도 안 갈린다).
|
||||||
|
#:
|
||||||
|
#: 채집석 공제는 사토에서 한 번만 뺀다.
|
||||||
|
#: B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며,
|
||||||
|
#: 빼는 자리는 유토곡선의 사토뿐이다 —
|
||||||
|
#: 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다.
|
||||||
|
#:
|
||||||
|
#: ⚠ **부호를 넘기지 않는다.** 실무 시트가 `−274.66` 으로 적혀 있어 그대로 넘기면 두 번
|
||||||
|
#: 뒤집힌다. 우리는 **양수**로 주고, 빼는 것은 받는 쪽이 한다.
|
||||||
|
COLLECTED_STONE_KEY = "collected_stone_deduction_m3"
|
||||||
|
|
||||||
|
BLINDING_THICKNESS_M = 0.10
|
||||||
|
#: 버림을 뺄 수 있는 칸 — 「기본은 넣고, 빼고 싶으면 뺀다」(사용자 확정 ⑭).
|
||||||
|
#: 저장 제원에 이 칸이 없으면 **넣는 쪽**이 기본이다.
|
||||||
|
BLINDING_OPTION_KEYS = ("blinding_concrete", "base_blinding")
|
||||||
|
|
||||||
DESTINATION = {
|
DESTINATION = {
|
||||||
"터파기": "earthwork",
|
"터파기": "earthwork",
|
||||||
"되메우기": "earthwork",
|
"되메우기": "earthwork",
|
||||||
@@ -169,9 +202,13 @@ DESTINATION = {
|
|||||||
"막자갈": "material",
|
"막자갈": "material",
|
||||||
"콘크리트": "unit_price",
|
"콘크리트": "unit_price",
|
||||||
"채움콘크리트": "unit_price",
|
"채움콘크리트": "unit_price",
|
||||||
|
"버림콘크리트": "unit_price",
|
||||||
"모르터": "unit_price",
|
"모르터": "unit_price",
|
||||||
"거푸집": "unit_price",
|
"거푸집": "unit_price",
|
||||||
"물구멍관": "material", # ⚠ 자재 카탈로그가 이름으로 찾는다 — 공백 없는 한 낱말(B09 규약)
|
"물구멍관": "material",
|
||||||
|
# ⚠ 자재도 토공도 아닌 자리 — **유토곡선이 사토에서 뺄 밑수**다. 자재총괄은
|
||||||
|
# `material` 만 모으므로 여기 섞이지 않는다.
|
||||||
|
"채집석": "haul_deduction", # ⚠ 자재 카탈로그가 이름으로 찾는다 — 공백 없는 한 낱말(B09 규약)
|
||||||
}
|
}
|
||||||
|
|
||||||
# ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다.
|
# ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다.
|
||||||
@@ -221,6 +258,48 @@ class StructureQuantity:
|
|||||||
billing_quantity: float = 0.0
|
billing_quantity: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def is_collected_stone(options: dict[str, Any]) -> bool:
|
||||||
|
"""이 구조물의 돌을 **캐서 쓰는가**. 정한 적이 없으면 「캔다」(확정 ②)."""
|
||||||
|
for key in STONE_SUPPLY_KEYS:
|
||||||
|
raw = options.get(key)
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
if str(raw).strip().lower() in STONE_SUPPLY_PURCHASED:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def wants_blinding(options: dict[str, Any]) -> bool:
|
||||||
|
"""버림 콘크리트를 넣을지. **정한 적이 없으면 넣는다**(사용자 확정 ⑭).
|
||||||
|
|
||||||
|
「안 넣음」·「제외」·`false` 로 적혀 있을 때만 뺀다 — 빈 칸을 「빼기」로 읽으면
|
||||||
|
저장해 둔 적 없는 프로젝트에서 줄이 통째로 사라진다.
|
||||||
|
"""
|
||||||
|
for key in BLINDING_OPTION_KEYS:
|
||||||
|
raw = options.get(key)
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
text = str(raw).strip().lower()
|
||||||
|
if text in {"false", "0", "no", "제외", "안 넣음", "안넣음", "빼기"}:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _blinding_component(base_width_m: float, length_m: float, options: dict[str, Any]):
|
||||||
|
"""버림 콘크리트 한 줄. 넣지 않기로 했으면 `None`."""
|
||||||
|
if not wants_blinding(options) or base_width_m <= 0 or length_m <= 0:
|
||||||
|
return None
|
||||||
|
return Component(
|
||||||
|
"버림콘크리트",
|
||||||
|
"㎥",
|
||||||
|
base_width_m * length_m * BLINDING_THICKNESS_M,
|
||||||
|
DESTINATION["버림콘크리트"],
|
||||||
|
f"기초 폭 {base_width_m:.2f}m × 연장 × 두께 {BLINDING_THICKNESS_M:.2f}m"
|
||||||
|
" · 두께는 KDS 44 90 00(100㎜) · ⚠ 폭은 잡석다짐 폭(KCS 34 50 05)이라야 하나"
|
||||||
|
" 그 값이 아직 없어 **터파기 폭을 잠정**으로 씀",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
#: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인).
|
#: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인).
|
||||||
#: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다**
|
#: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다**
|
||||||
#: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다.
|
#: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다.
|
||||||
@@ -325,6 +404,10 @@ def boulder_masonry(
|
|||||||
# 낸다. 근거 문구에 그 사실을 적어 되짚을 수 있게 한다.
|
# 낸다. 근거 문구에 그 사실을 적어 되짚을 수 있게 한다.
|
||||||
upper_cm = float(str(diameter).split("~")[-1])
|
upper_cm = float(str(diameter).split("~")[-1])
|
||||||
thickness = upper_cm / 100.0
|
thickness = upper_cm / 100.0
|
||||||
|
# 버림 콘크리트 — 돌쌓기와 같은 자리(사용자 확정 ⑭). 빼려면 저장 제원에서 「안 넣음」.
|
||||||
|
blinding = _blinding_component(thickness + constants["excavation_extra_m"], length_m, options)
|
||||||
|
if blinding is not None:
|
||||||
|
components.append(blinding)
|
||||||
excavation = height_m * (thickness + constants["excavation_extra_m"]) * length_m
|
excavation = height_m * (thickness + constants["excavation_extra_m"]) * length_m
|
||||||
backfill = height_m * constants["backfill_thickness_m"] * length_m
|
backfill = height_m * constants["backfill_thickness_m"] * length_m
|
||||||
components.extend(
|
components.extend(
|
||||||
@@ -470,7 +553,8 @@ def stone_masonry(
|
|||||||
# 막자갈 = 입적 − (면적 × 뒷길이 × 뒤채움몫 + 고임돌).
|
# 막자갈 = 입적 − (면적 × 뒷길이 × 뒤채움몫 + 고임돌).
|
||||||
# 뒤채움 몫은 **돌 종류로 갈린다** — 깬돌·잡석 1/2 · 야면석 1/3 (교본 7-3).
|
# 뒤채움 몫은 **돌 종류로 갈린다** — 깬돌·잡석 1/2 · 야면석 1/3 (교본 7-3).
|
||||||
wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0 — 막자갈에서 안 뺌
|
wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0 — 막자갈에서 안 뺌
|
||||||
rubble = volume - (masonry_area * (back_cm / 100.0) * body_ratio + wedge)
|
stone_body = masonry_area * (back_cm / 100.0) * body_ratio # 면석 몸통 체적
|
||||||
|
rubble = volume - (stone_body + wedge)
|
||||||
if rubble > 0:
|
if rubble > 0:
|
||||||
components.append(
|
components.append(
|
||||||
Component(
|
Component(
|
||||||
@@ -527,6 +611,28 @@ def stone_masonry(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.**
|
||||||
|
if is_collected_stone(options):
|
||||||
|
collected = max(stone_body + wedge + max(rubble, 0.0), 0.0)
|
||||||
|
if collected > 0:
|
||||||
|
components.append(
|
||||||
|
Component(
|
||||||
|
"채집석",
|
||||||
|
"㎥",
|
||||||
|
collected,
|
||||||
|
DESTINATION["채집석"],
|
||||||
|
f"면석 몸통 {stone_body:.3f} + 고임돌 {wedge:.3f} + 막자갈"
|
||||||
|
f" {max(rubble, 0.0):.3f} ㎥ · 현장 채집분 ·"
|
||||||
|
" ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 버림 콘크리트 — 기초 바닥에 까는 얇은 층. 빼려면 저장 제원에서 「안 넣음」으로 둔다.
|
||||||
|
base_width = thickness + constants["excavation_extra_m"]
|
||||||
|
blinding = _blinding_component(base_width, length_m, options)
|
||||||
|
if blinding is not None:
|
||||||
|
components.append(blinding)
|
||||||
|
|
||||||
# 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다).
|
# 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다).
|
||||||
excavation = height_m * (thickness + constants["excavation_extra_m"]) * length_m
|
excavation = height_m * (thickness + constants["excavation_extra_m"]) * length_m
|
||||||
backfill = height_m * constants["backfill_thickness_m"] * length_m
|
backfill = height_m * constants["backfill_thickness_m"] * length_m
|
||||||
@@ -781,5 +887,15 @@ def build_table(
|
|||||||
"surcharge_applied": False,
|
"surcharge_applied": False,
|
||||||
"mix_components_found": violations,
|
"mix_components_found": violations,
|
||||||
"structure_count": len(quantities),
|
"structure_count": len(quantities),
|
||||||
|
# ⚠ 사토에서 뺄 밑수 — **양수**로 낸다. 빼는 것은 유토곡선(랩탑 메인) 몫이다.
|
||||||
|
COLLECTED_STONE_KEY: round(
|
||||||
|
sum(
|
||||||
|
component.amount
|
||||||
|
for item in quantities
|
||||||
|
for component in item.components
|
||||||
|
if component.name == "채집석"
|
||||||
|
),
|
||||||
|
3,
|
||||||
|
),
|
||||||
"amount_spread": spread_by_unit(totals.values(), value_key="amount"),
|
"amount_spread": spread_by_unit(totals.values(), value_key="amount"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
|||||||
slope.get("rows") or [],
|
slope.get("rows") or [],
|
||||||
settings.get("topsoil_thickness_m"),
|
settings.get("topsoil_thickness_m"),
|
||||||
{type_id: definition.name for type_id, definition in structure_type_map().items()},
|
{type_id: definition.name for type_id, definition in structure_type_map().items()},
|
||||||
|
# 부대시설 개소 — 산식으로 만들지 않고 **설계자가 넣은 값**만 쓴다(확정 ⑬).
|
||||||
|
settings.get("ancillary_counts") or {},
|
||||||
)
|
)
|
||||||
method, method_is_default = concrete_placing_method(settings)
|
method, method_is_default = concrete_placing_method(settings)
|
||||||
# ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다.
|
# ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다.
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""B09 원가계산 — **목록표·집계표** (사용자 확정 12번: 내야 할 표 16개 전체).
|
||||||
|
|
||||||
|
지금까지 안 내던 일곱 표 중 여섯이 여기서 난다.
|
||||||
|
|
||||||
|
A5-1 중기목록표 코드·명칭·규격·단위 · **합계·노무비·재료비·경비** · 비고
|
||||||
|
A6 노무비목록표 코드·명칭·규격·단위 · **단가** · 비고
|
||||||
|
A7 재료비목록표 〃
|
||||||
|
A8 경비목록표 〃 (기계 취득가 `S-` 층이 여기 온다)
|
||||||
|
A11 자원 집계표 코드·명칭·규격 · **수량** · 단위 · 단가 · **금액** · 비고
|
||||||
|
— 노무비·재료비·경비·중기 네 벌
|
||||||
|
|
||||||
|
**서식은 지어내지 않았다** — 실무 내역서(영월 기번6 · 봉화 기번41)의 같은 이름 시트를
|
||||||
|
그대로 옮겼다(2026-09-09 실측). 칸 이름·차례가 그 시트와 같다.
|
||||||
|
|
||||||
|
⚠ **새 계산이 아니다.** 목록표는 `PriceBook` 의 제목을 종류별로 늘어놓는 것이고,
|
||||||
|
집계표는 **내역서에 이미 선 금액을 자원별로 되모으는 것**이다. 값을 여기서 다시 만들면
|
||||||
|
내역서와 어긋난다(CLAUDE.md 5장 「같은 계산을 두 벌로 짜지 않는다」).
|
||||||
|
|
||||||
|
⚠ **집계표는 반올림**이다(단수 규칙 `RESOURCE_SUMMARY`). 내역서 본체는 절사라
|
||||||
|
**두 표의 합이 원 단위로 어긋나는 것이 정상**이다 — 그 사실을 화면에 함께 낸다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import PriceKind
|
||||||
|
from B09_Estimation.B09_Estimation_Rounding import (
|
||||||
|
SUMMARY_MISMATCH_NOTE,
|
||||||
|
OutputPlace,
|
||||||
|
round_at,
|
||||||
|
)
|
||||||
|
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
|
||||||
|
|
||||||
|
_ZERO = Decimal(0)
|
||||||
|
|
||||||
|
#: 목록표 한 장이 담는 종류. 실무 시트 이름 그대로 쓴다.
|
||||||
|
LIST_KINDS: tuple[tuple[str, str, PriceKind], ...] = (
|
||||||
|
("labor", "노무비목록표", PriceKind.LABOR),
|
||||||
|
("material", "재료비목록표", PriceKind.MATERIAL),
|
||||||
|
("expense", "경비목록표", PriceKind.MACHINE_BASE),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _money(value: Decimal | None) -> str | None:
|
||||||
|
return None if value is None else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_list(build: UnitPriceBuild, kind: PriceKind) -> list[dict[str, Any]]:
|
||||||
|
"""목록표 한 장 — 그 종류의 **기초단가 줄**을 코드 차례로 늘어놓는다.
|
||||||
|
|
||||||
|
⚠ 단가가 안 선 줄도 **빼지 않는다.** 빼면 「없는 것」과 「값을 못 구한 것」이 같아 보인다.
|
||||||
|
"""
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for code in sorted(build.book.titles):
|
||||||
|
title = build.book.titles[code]
|
||||||
|
if title.kind is not kind:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
price: Decimal | None = title.adopted_price()
|
||||||
|
note = ""
|
||||||
|
except Exception as error: # 채택 슬롯이 비었다 — 값을 지어내지 않는다
|
||||||
|
price, note = None, str(error)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"code": code,
|
||||||
|
"name": title.name,
|
||||||
|
"spec": title.spec,
|
||||||
|
"unit": title.unit,
|
||||||
|
"unit_price_krw": _money(price),
|
||||||
|
"note": note,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def machine_base_list() -> list[dict[str, Any]]:
|
||||||
|
"""경비목록표 — **기계 취득가격(천원)** 목록.
|
||||||
|
|
||||||
|
⚠ 내 `S-` 층과 **다른 값**이다. `S-` 는 「취득가 × 시간당 손료계수」라 **원/시간**이고,
|
||||||
|
실무 경비목록표는 **취득가 그 자체를 천원 단위**로 싣는다(영월 실측:
|
||||||
|
`S00104 불도저(무한궤도) 19톤 **천원** 184,499`). 손료를 여기 실으면 자릿수가 세 자리
|
||||||
|
어긋난 채 「경비」로 읽힌다.
|
||||||
|
"""
|
||||||
|
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
|
||||||
|
|
||||||
|
catalog = load_machine_catalog()
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for code in sorted(catalog.machines):
|
||||||
|
machine = catalog.machines[code]
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"code": f"S-{code}",
|
||||||
|
"name": machine.name,
|
||||||
|
"spec": machine.specification,
|
||||||
|
"unit": "천원",
|
||||||
|
"unit_price_krw": _money(machine.price_thousand_krw),
|
||||||
|
"note": "" if machine.loss_coefficient_per_hour is not None else "손료계수 미확보",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def machine_list(build: UnitPriceBuild) -> list[dict[str, Any]]:
|
||||||
|
"""중기목록표 — 시간당 사용료를 **3분할까지** 보인다 (실무 시트와 같은 칸).
|
||||||
|
|
||||||
|
실무 서식: `X00205 굴삭기(무한궤도) 0.7㎥ 시간 96,843 = 노무 55,700 + 재료 18,015 + 경비 23,128`
|
||||||
|
"""
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for code in sorted(build.book.titles):
|
||||||
|
title = build.book.titles[code]
|
||||||
|
if title.kind is not PriceKind.MACHINE_HOURLY:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
money = build.book.resolve(code)
|
||||||
|
row = {
|
||||||
|
"total_krw": _money(round_at(money.total, OutputPlace.UNIT_PRICE_ROW)),
|
||||||
|
"labor_krw": _money(round_at(money.labor, OutputPlace.UNIT_PRICE_ROW)),
|
||||||
|
"material_krw": _money(round_at(money.material, OutputPlace.UNIT_PRICE_ROW)),
|
||||||
|
"expense_krw": _money(round_at(money.expense, OutputPlace.UNIT_PRICE_ROW)),
|
||||||
|
"note": "",
|
||||||
|
}
|
||||||
|
except Exception as error: # 층이 덜 섰다 — 0 으로 안 때운다
|
||||||
|
row = {
|
||||||
|
"total_krw": None,
|
||||||
|
"labor_krw": None,
|
||||||
|
"material_krw": None,
|
||||||
|
"expense_krw": None,
|
||||||
|
"note": str(error),
|
||||||
|
}
|
||||||
|
rows.append(
|
||||||
|
{"code": code, "name": title.name, "spec": title.spec, "unit": title.unit, **row}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def resource_summary(
|
||||||
|
quantities: dict[str, Decimal],
|
||||||
|
build: UnitPriceBuild | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""자원 집계표 — 공종 수량을 **자원별로 되모은다**.
|
||||||
|
|
||||||
|
`quantities` = `{공종코드: 수량}` (내역서가 쓰는 것과 같은 모양).
|
||||||
|
한 자원이 여러 공종에 걸리면 **한 줄로 합친다** — 실무 시트가 그 모양이다.
|
||||||
|
|
||||||
|
⚠ **일위대가 안쪽을 한 겹만 편다.** 일위대가 → 자원(노무·자재·기계 사용료)까지가
|
||||||
|
실무 집계표의 깊이다. 기계 사용료(`X-`)를 다시 손료·연료로 쪼개면 **중기 집계표와
|
||||||
|
이중으로 세는 것**이 된다.
|
||||||
|
"""
|
||||||
|
prices = build or cached_build()
|
||||||
|
book = prices.book
|
||||||
|
#: 자원코드 → [수량, 제목]
|
||||||
|
picked: dict[str, list[Any]] = {}
|
||||||
|
missing: list[str] = []
|
||||||
|
|
||||||
|
for raw_code, quantity in quantities.items():
|
||||||
|
code = raw_code if raw_code.startswith("B-") else f"B-{raw_code}"
|
||||||
|
if code not in book.titles:
|
||||||
|
missing.append(raw_code)
|
||||||
|
continue
|
||||||
|
amount = Decimal(str(quantity))
|
||||||
|
for detail in book.details.get(code, []):
|
||||||
|
if detail.percent_of_labor is not None or detail.percent_of_parent is not None:
|
||||||
|
continue # 비율 줄은 자원이 아니다 — 경비로만 붙는다
|
||||||
|
ref = detail.ref_code
|
||||||
|
if ref == code:
|
||||||
|
continue
|
||||||
|
slot = picked.setdefault(ref, [_ZERO, book.titles.get(ref)])
|
||||||
|
slot[0] += detail.quantity * amount
|
||||||
|
|
||||||
|
groups: dict[str, list[dict[str, Any]]] = {
|
||||||
|
"labor": [],
|
||||||
|
"material": [],
|
||||||
|
"expense": [],
|
||||||
|
"machine": [],
|
||||||
|
}
|
||||||
|
for ref, (amount, title) in sorted(picked.items()):
|
||||||
|
if title is None:
|
||||||
|
missing.append(ref)
|
||||||
|
continue
|
||||||
|
bucket = {
|
||||||
|
PriceKind.LABOR: "labor",
|
||||||
|
PriceKind.MATERIAL: "material",
|
||||||
|
PriceKind.MACHINE_BASE: "expense",
|
||||||
|
PriceKind.MACHINE_HOURLY: "machine",
|
||||||
|
}.get(title.kind)
|
||||||
|
if bucket is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
unit_money = book.resolve(ref)
|
||||||
|
unit_price: Decimal | None = unit_money.total
|
||||||
|
# ⚠ 집계표는 **반올림** — 내역서 본체(절사)와 원 단위로 어긋나는 것이 정상이다.
|
||||||
|
money: Decimal | None = round_at(
|
||||||
|
unit_money.total * amount, OutputPlace.RESOURCE_SUMMARY
|
||||||
|
)
|
||||||
|
note = ""
|
||||||
|
except Exception as error:
|
||||||
|
unit_price, money, note = None, None, str(error)
|
||||||
|
groups[bucket].append(
|
||||||
|
{
|
||||||
|
"code": ref,
|
||||||
|
"name": title.name,
|
||||||
|
"spec": title.spec,
|
||||||
|
"quantity": str(amount),
|
||||||
|
"unit": title.unit,
|
||||||
|
"unit_price_krw": _money(unit_price),
|
||||||
|
"amount_krw": _money(money),
|
||||||
|
"note": note,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"groups": groups,
|
||||||
|
"missing": sorted(set(missing)),
|
||||||
|
"note": SUMMARY_MISMATCH_NOTE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def all_lists(build: UnitPriceBuild | None = None) -> dict[str, Any]:
|
||||||
|
"""목록표 넷을 한 번에 — 화면이 탭 하나에서 다 쓴다."""
|
||||||
|
prices = build or cached_build()
|
||||||
|
return {
|
||||||
|
"labor": catalog_list(prices, PriceKind.LABOR),
|
||||||
|
"material": catalog_list(prices, PriceKind.MATERIAL),
|
||||||
|
"expense": machine_base_list(),
|
||||||
|
"machine": machine_list(prices),
|
||||||
|
}
|
||||||
@@ -217,6 +217,25 @@ async def list_unit_price_titles(project_id: UUID) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/estimation/base-data")
|
||||||
|
async def get_base_data_lists(project_id: UUID) -> JSONResponse:
|
||||||
|
"""**기초자료 네 표** — 노무비·재료비·경비 목록표 + 중기목록표 (사용자 확정 12번).
|
||||||
|
|
||||||
|
별표2 설계서 구성에 드는 표들이라 **없으면 설계서가 성립하지 않는다.** 서식은
|
||||||
|
실무 내역서(영월 기번6·봉화 기번41) 같은 이름 시트를 그대로 따랐다.
|
||||||
|
"""
|
||||||
|
from B09_Estimation.B09_Estimation_Lists import all_lists
|
||||||
|
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"status": "success", **all_lists(cached_build())})
|
||||||
|
except Exception:
|
||||||
|
logger.exception("B09 기초자료 목록 실패: project_id=%s", project_id)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"status": "error", "message": "기초자료 목록을 못 만들었습니다."},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/estimation/unit-prices/{code}")
|
@router.get("/{project_id}/estimation/unit-prices/{code}")
|
||||||
async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse:
|
async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse:
|
||||||
"""일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다."""
|
"""일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다."""
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import type { BermSpec } from "./common_util_cross_berm";
|
import type { BermSpec } from "./common_util_cross_berm";
|
||||||
import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas";
|
import { benchCutLength, splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas";
|
||||||
// 단면 기하(노면·측구·사면 설계고)는 파일이 700줄을 넘어 떼어냈다(2026-09-04).
|
// 단면 기하(노면·측구·사면 설계고)는 파일이 700줄을 넘어 떼어냈다(2026-09-04).
|
||||||
import {
|
import {
|
||||||
CURVE_WIDENING_MAX_WIDTH_M,
|
CURVE_WIDENING_MAX_WIDTH_M,
|
||||||
@@ -131,6 +131,8 @@ export interface CrossDesignResult {
|
|||||||
cut_rock_area_m2: number;
|
cut_rock_area_m2: number;
|
||||||
cut_rock_kind: string | null;
|
cut_rock_kind: string | null;
|
||||||
fill_area_m2: number;
|
fill_area_m2: number;
|
||||||
|
/** 층따기 밑수 — 성토부 아래 원지반(1:4 보다 급한 구간)의 지표면 길이(m). */
|
||||||
|
bench_cut_length_m: number;
|
||||||
slope_unclosed: boolean;
|
slope_unclosed: boolean;
|
||||||
fill_ground_slope: number | null;
|
fill_ground_slope: number | null;
|
||||||
ditch_area_m2: number;
|
ditch_area_m2: number;
|
||||||
@@ -331,18 +333,22 @@ export function computeCrossDesign(
|
|||||||
const merged = [...mergedSet].sort((a, b) => a - b);
|
const merged = [...mergedSet].sort((a, b) => a - b);
|
||||||
|
|
||||||
const offsets: number[] = [];
|
const offsets: number[] = [];
|
||||||
|
const grounds: number[] = [];
|
||||||
const diffs: number[] = [];
|
const diffs: number[] = [];
|
||||||
const designLine: CrossDesignEdge[] = [];
|
const designLine: CrossDesignEdge[] = [];
|
||||||
for (const offsetM of merged) {
|
for (const offsetM of merged) {
|
||||||
const groundM = groundAt(offsetM);
|
const groundM = groundAt(offsetM);
|
||||||
const designZ = geometry.designZ(offsetM, groundM);
|
const designZ = geometry.designZ(offsetM, groundM);
|
||||||
offsets.push(offsetM);
|
offsets.push(offsetM);
|
||||||
|
grounds.push(groundM);
|
||||||
diffs.push(groundM - designZ);
|
diffs.push(groundM - designZ);
|
||||||
designLine.push({ offset_m: round4(offsetM), elevation_m: round4(designZ) });
|
designLine.push({ offset_m: round4(offsetM), elevation_m: round4(designZ) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음).
|
// 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음).
|
||||||
const [cutArea, fillArea] = trapezoidAreas(offsets, diffs);
|
const [cutArea, fillArea] = trapezoidAreas(offsets, diffs);
|
||||||
|
// 층따기 밑수(길이 m) — 성토부 아래 원지반이 1:4 보다 급한 구간의 지표면 길이.
|
||||||
|
const benchCut = benchCutLength(offsets, grounds, diffs);
|
||||||
const fillGroundSlope = geometry.fillGroundSlope();
|
const fillGroundSlope = geometry.fillGroundSlope();
|
||||||
const slopeUnclosed =
|
const slopeUnclosed =
|
||||||
diffs.length > 0 &&
|
diffs.length > 0 &&
|
||||||
@@ -447,6 +453,7 @@ export function computeCrossDesign(
|
|||||||
cut_rock_area_m2: round4(cutRockArea),
|
cut_rock_area_m2: round4(cutRockArea),
|
||||||
cut_rock_kind: cutRockKind,
|
cut_rock_kind: cutRockKind,
|
||||||
fill_area_m2: round4(fillArea),
|
fill_area_m2: round4(fillArea),
|
||||||
|
bench_cut_length_m: round4(benchCut),
|
||||||
slope_unclosed: slopeUnclosed,
|
slope_unclosed: slopeUnclosed,
|
||||||
fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope),
|
fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope),
|
||||||
ditch_area_m2: round4(ditchArea),
|
ditch_area_m2: round4(ditchArea),
|
||||||
|
|||||||
@@ -95,3 +95,34 @@ export function splitCutAreas(
|
|||||||
}
|
}
|
||||||
return [soilArea, rockArea];
|
return [soilArea, rockArea];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 층따기 대상 판정 기울기 — 원지반 횡단기울기 1:4(=25%)보다 급한 곳에만 한다.
|
||||||
|
* 근거: 별표2 · 임도기술교본 6장 4절(「1:4보다 급한 경사를 가진 지반 위에 성토」).
|
||||||
|
* ⚠ 파이썬 짝: `B06_Section_Engine_Areas._BENCH_CUT_MIN_GROUND_SLOPE`. */
|
||||||
|
export const BENCH_CUT_MIN_GROUND_SLOPE = 0.25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 층따기 밑수 — **성토부 아래 원지반 표면의 경사길이(m)**.
|
||||||
|
* ⚠ 파이썬 짝: `B06_Section_Engine_Areas._bench_cut_length`. 한 벌로 움직인다.
|
||||||
|
* 면적(㎡)은 측점 사이를 평균단면적법으로 이어 B08 이 낸다.
|
||||||
|
*/
|
||||||
|
export function benchCutLength(offsets: number[], grounds: number[], diffs: number[]): number {
|
||||||
|
let total = 0;
|
||||||
|
for (let index = 1; index < offsets.length; index += 1) {
|
||||||
|
const run = offsets[index] - offsets[index - 1];
|
||||||
|
if (run <= 0) continue;
|
||||||
|
const d0 = diffs[index - 1];
|
||||||
|
const d1 = diffs[index];
|
||||||
|
if (d0 >= 0 && d1 >= 0) continue;
|
||||||
|
let share = 1;
|
||||||
|
if (d0 * d1 < 0) {
|
||||||
|
const zeroRatio = d0 / (d0 - d1);
|
||||||
|
share = d0 > 0 ? 1 - zeroRatio : zeroRatio;
|
||||||
|
}
|
||||||
|
if (share <= 0) continue;
|
||||||
|
const rise = grounds[index] - grounds[index - 1];
|
||||||
|
if (Math.abs(rise) / run < BENCH_CUT_MIN_GROUND_SLOPE) continue;
|
||||||
|
total += Math.sqrt(run * run + rise * rise) * share;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|||||||
@@ -192,6 +192,13 @@ export interface HaulPlan {
|
|||||||
borrow_m3: number;
|
borrow_m3: number;
|
||||||
/** 사토 중 자연방토 몫(㎥) — 운반비를 세지 않는다. */
|
/** 사토 중 자연방토 몫(㎥) — 운반비를 세지 않는다. */
|
||||||
natural_spoil_m3: number;
|
natural_spoil_m3: number;
|
||||||
|
/**
|
||||||
|
* 채집석 공제로 받은 값(㎥, 양수). **`null` 은 「아직 안 옴」**이고 `0` 은 「공제 없음」이다 —
|
||||||
|
* 둘을 같게 보면 값이 안 온 것을 공제 0 으로 읽어 조용히 넘어간다(2026-09-09).
|
||||||
|
*/
|
||||||
|
collected_stone_deduction_m3: number | null;
|
||||||
|
/** 실제로 사토에서 뺀 양(㎥). 사토가 모자라면 받은 값보다 작을 수 있다. */
|
||||||
|
collected_stone_deducted_m3: number;
|
||||||
/** 블록 안에서 옮기는 양(㎥). */
|
/** 블록 안에서 옮기는 양(㎥). */
|
||||||
hauled_m3: number;
|
hauled_m3: number;
|
||||||
/** 떨어진 구간끼리 장거리로 옮기는 양(㎥). */
|
/** 떨어진 구간끼리 장거리로 옮기는 양(㎥). */
|
||||||
@@ -316,9 +323,54 @@ function bandOutline(
|
|||||||
* 누가토량 곡선에서 토량 분배(평형선·운반 블록·장비 띠·사토/토취)를 뽑는다.
|
* 누가토량 곡선에서 토량 분배(평형선·운반 블록·장비 띠·사토/토취)를 뽑는다.
|
||||||
* 블록도 잔량도 안 나오면(평탄한 곡선) null.
|
* 블록도 잔량도 안 나오면(평탄한 곡선) null.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 채집석 공제 — **사토에서 한 번만 뺀다**(2026-09-09 사용자 확정, 네 창 합의 문구).
|
||||||
|
*
|
||||||
|
* 채집석 공제는 사토에서 한 번만 뺀다.
|
||||||
|
* B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며,
|
||||||
|
* 빼는 자리는 유토곡선의 사토뿐이다 —
|
||||||
|
* 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다.
|
||||||
|
*
|
||||||
|
* ⚠ 순서가 중요하다 — 캔 돌은 **실어 낼 흙 속에 있던 것**이다. 자연방토(운반비를 안 세는 몫)
|
||||||
|
* 에서 먼저 깎으면 **줄어야 할 운반비가 안 줄어든다.**
|
||||||
|
* ⚠ 잔량 하나하나(`residuals`)를 줄인다 — 총량만 줄이면 사토 balloon·운반거리가 안 따라간다.
|
||||||
|
*
|
||||||
|
* 돌려주는 값은 **실제로 뺀 양(㎥)**. 사토가 모자라면 받은 값보다 작다.
|
||||||
|
*/
|
||||||
|
function applyCollectedStoneDeduction(residuals: HaulResidual[], deduction: number | null): number {
|
||||||
|
if (deduction === null || !Number.isFinite(deduction) || deduction <= 0) return 0;
|
||||||
|
const spoils = residuals.filter((residual) => residual.kind === "spoil");
|
||||||
|
let left = deduction;
|
||||||
|
const take = (residual: HaulResidual, amount: number): void => {
|
||||||
|
if (amount <= 0) return;
|
||||||
|
const before = residual.volume_m3;
|
||||||
|
const ratio = before > 0 ? (before - amount) / before : 0;
|
||||||
|
residual.volume_m3 = before - amount;
|
||||||
|
// 지반유형 안분도 같은 비율로 줄인다 — 남은 사토의 구성비는 그대로다.
|
||||||
|
residual.ea_m3 *= ratio;
|
||||||
|
residual.rr_m3 *= ratio;
|
||||||
|
residual.br_m3 *= ratio;
|
||||||
|
left -= amount;
|
||||||
|
};
|
||||||
|
// ① 실어 내는 몫부터
|
||||||
|
for (const residual of spoils) {
|
||||||
|
if (left <= EPSILON) break;
|
||||||
|
take(residual, Math.min(Math.max(residual.volume_m3 - residual.natural_m3, 0), left));
|
||||||
|
}
|
||||||
|
// ② 모자라면 자연방토에서
|
||||||
|
for (const residual of spoils) {
|
||||||
|
if (left <= EPSILON) break;
|
||||||
|
const amount = Math.min(residual.natural_m3, left);
|
||||||
|
residual.natural_m3 -= amount;
|
||||||
|
take(residual, amount);
|
||||||
|
}
|
||||||
|
return deduction - Math.max(left, 0);
|
||||||
|
}
|
||||||
|
|
||||||
export function computeHaulPlan(
|
export function computeHaulPlan(
|
||||||
result: MassHaulResult,
|
result: MassHaulResult,
|
||||||
limits: HaulEquipmentLimit[] | undefined,
|
limits: HaulEquipmentLimit[] | undefined,
|
||||||
|
options?: { collected_stone_deduction_m3?: number | null },
|
||||||
): HaulPlan | null {
|
): HaulPlan | null {
|
||||||
const points = result.points;
|
const points = result.points;
|
||||||
if (points.length < 2) return null;
|
if (points.length < 2) return null;
|
||||||
@@ -497,10 +549,17 @@ export function computeHaulPlan(
|
|||||||
residual.index = index + 1;
|
residual.index = index + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deductionInput = options?.collected_stone_deduction_m3 ?? null;
|
||||||
|
const deducted = applyCollectedStoneDeduction(settled, deductionInput);
|
||||||
|
const remaining = settled.filter((residual) => residual.volume_m3 > EPSILON);
|
||||||
|
remaining.forEach((residual, index) => {
|
||||||
|
residual.index = index + 1;
|
||||||
|
});
|
||||||
|
|
||||||
let spoil = 0;
|
let spoil = 0;
|
||||||
let borrow = 0;
|
let borrow = 0;
|
||||||
let naturalSpoil = 0;
|
let naturalSpoil = 0;
|
||||||
for (const residual of settled) {
|
for (const residual of remaining) {
|
||||||
if (residual.kind === "spoil") {
|
if (residual.kind === "spoil") {
|
||||||
spoil += residual.volume_m3;
|
spoil += residual.volume_m3;
|
||||||
naturalSpoil += residual.natural_m3;
|
naturalSpoil += residual.natural_m3;
|
||||||
@@ -510,12 +569,14 @@ export function computeHaulPlan(
|
|||||||
for (const point of points) fillTotal += point.fill_m3;
|
for (const point of points) fillTotal += point.fill_m3;
|
||||||
return {
|
return {
|
||||||
blocks,
|
blocks,
|
||||||
residuals: settled,
|
residuals: remaining,
|
||||||
transfers,
|
transfers,
|
||||||
steps,
|
steps,
|
||||||
spoil_m3: spoil,
|
spoil_m3: spoil,
|
||||||
borrow_m3: borrow,
|
borrow_m3: borrow,
|
||||||
natural_spoil_m3: naturalSpoil,
|
natural_spoil_m3: naturalSpoil,
|
||||||
|
collected_stone_deduction_m3: deductionInput,
|
||||||
|
collected_stone_deducted_m3: deducted,
|
||||||
hauled_m3: blocks.reduce((sum, block) => sum + block.volume_m3, 0),
|
hauled_m3: blocks.reduce((sum, block) => sum + block.volume_m3, 0),
|
||||||
transferred_m3: transfers.reduce((sum, entry) => sum + entry.volume_m3, 0),
|
transferred_m3: transfers.reduce((sum, entry) => sum + entry.volume_m3, 0),
|
||||||
fill_total_m3: fillTotal,
|
fill_total_m3: fillTotal,
|
||||||
|
|||||||
@@ -102,6 +102,13 @@ def default_settings() -> dict[str, Any]:
|
|||||||
# ⚠ 기본은 `None` — 「안 정함」과 「일부러 레디믹스트를 고른 것」을 갈라야
|
# ⚠ 기본은 `None` — 「안 정함」과 「일부러 레디믹스트를 고른 것」을 갈라야
|
||||||
# 화면이 「기본값 적용 중」을 정직하게 띄운다. 값을 미리 넣으면 그 구별이 사라진다.
|
# 화면이 「기본값 적용 중」을 정직하게 띄운다. 값을 미리 넣으면 그 구별이 사라진다.
|
||||||
"concrete_placing_method": None,
|
"concrete_placing_method": None,
|
||||||
|
# 부대시설 개소 — `{항목키: 개소}` (2026-09-09 사용자 확정 ⑬).
|
||||||
|
# ⚠ **산식으로 만들지 않는다.** 국가지점번호판은 임도규정 제26조제5항이
|
||||||
|
# 「500미터 마다 설치·관리하되 **필요시 거리를 조정**」이라 하고, 기점 포함·
|
||||||
|
# 종점 잔여·갈림길 중복을 원문이 안 정한다(지식DB 부대시설 [구현]).
|
||||||
|
# ⇒ `ceil(연장÷500)` 을 확정 산식으로 쓰지 않고 **개소를 받는다.**
|
||||||
|
# 비워 두면 물량을 안 낸다(0 으로 때우지 않음).
|
||||||
|
"ancillary_counts": {},
|
||||||
"dataset_versions": {},
|
"dataset_versions": {},
|
||||||
},
|
},
|
||||||
"estimation": {
|
"estimation": {
|
||||||
|
|||||||
@@ -75,10 +75,9 @@
|
|||||||
"group": "층따기",
|
"group": "층따기",
|
||||||
"work_item_code": "FP-09-18",
|
"work_item_code": "FP-09-18",
|
||||||
"master_name": "층따기",
|
"master_name": "층따기",
|
||||||
"basis_unit": "㎥",
|
"basis_unit": "㎡",
|
||||||
"basis_source": "품셈 9-18 [주] 「Q1 = 3600×q×K×f×E/㎝ = ㎥/시간」 — 절 머리에 「(단위: …)」가 없고 **공식으로만** 단위가 밝혀지는 자리라 마스터 `basis_unit` 이 비어 있다(2026-09-08 B09 확인).",
|
"basis_source": "⭐ 2026-09-09 사용자 확정 ⑦ — 층따기 수량 단위는 **㎡**(실무 관행). 밑수는 **원지반 표면의 경사길이**(B06 `design.bench_cut_length_m`)를 평균단면적법으로 면적화한 값이다. ⚠ 품셈 9-18 [주]는 `Q1 = 3600×q×K×f×E/㎝ = ㎥/시간` 이라 **공식은 체적 기준**이다 — 단의 높이·폭이 설계도서 값이라 체적을 지어낼 수 없어 면적으로 간다. ⇒ **받는 쪽(B09)이 ㎡ 단가를 세워야 한다**(㎥ 단가를 그대로 곱하면 금액이 틀림).",
|
||||||
"mismatch_reason": "층따기는 품셈이 **체적(㎥)**으로 세는데 우리 집계는 **성토 비탈면적(㎡)** 입니다 — 층따기 단의 높이·폭이 있어야 체적이 나옵니다(교본: 「층따기 높이·폭은 설계도서에 명시」). 그 값이 정해지면 물량이 섭니다.",
|
"unit_note": "앞서 2026-09-08 에는 이 어긋남을 「막힘」으로 두어 금액이 안 섰다(410만원 자리). 사용자 확정으로 ㎡ 가 정본이 되었으므로 막지 않는다."
|
||||||
"mismatch_kind": "input_missing"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"group": "면고르기",
|
"group": "면고르기",
|
||||||
|
|||||||
Reference in New Issue
Block a user