Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -22,9 +22,20 @@ from B09_Estimation.B09_Estimation_BillOfQuantities import (
|
|||||||
_MasterNode,
|
_MasterNode,
|
||||||
_decimal,
|
_decimal,
|
||||||
)
|
)
|
||||||
|
from B09_Estimation.B09_Estimation_MachineProductivity_Dump import (
|
||||||
|
DUMP_PARENT,
|
||||||
|
LOADING_EQUIPMENT,
|
||||||
|
dump_child_for,
|
||||||
|
dump_title_code,
|
||||||
|
loading_title_code,
|
||||||
|
)
|
||||||
from B09_Estimation.B09_Estimation_PriceBook import Money3
|
from B09_Estimation.B09_Estimation_PriceBook import Money3
|
||||||
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
||||||
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, find_variant_code
|
from B09_Estimation.B09_Estimation_UnitPrice import (
|
||||||
|
UnitPriceBuild,
|
||||||
|
_master_edition,
|
||||||
|
find_variant_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def bill_line(unit: Money3, quantity) -> Money3:
|
def bill_line(unit: Money3, quantity) -> Money3:
|
||||||
@@ -303,6 +314,41 @@ def _leaf_row(
|
|||||||
return row
|
return row
|
||||||
|
|
||||||
price_code = f"B-{node.code}"
|
price_code = f"B-{node.code}"
|
||||||
|
loading = item.haul_equipment == LOADING_EQUIPMENT
|
||||||
|
if node.code == DUMP_PARENT and (item.haul_equipment == "dump_truck" or loading):
|
||||||
|
# 덤프 운반(10-12) — 잎(토사·암절취·발파암) × 운반거리마다 호표 한 장. 거리 없으면 0원 금지.
|
||||||
|
# 적재 짝 줄은 거리 무관 `#적재` 한 장.
|
||||||
|
child = dump_child_for(item.variant_value, _master_edition())
|
||||||
|
why = ""
|
||||||
|
wanted = ""
|
||||||
|
if child is None:
|
||||||
|
why = (
|
||||||
|
f"덤프 운반 갈래 「{item.variant_value or '미지정'}」를 10-12 잎"
|
||||||
|
"(토사·암절취·발파암)에 못 맞춤"
|
||||||
|
)
|
||||||
|
elif loading:
|
||||||
|
wanted = loading_title_code(child)
|
||||||
|
elif item.haul_distance_m is None:
|
||||||
|
why = "입력이 필요합니다 — 운반거리 미입력(유토곡선·사토장 거리가 서면 섬)"
|
||||||
|
else:
|
||||||
|
wanted = dump_title_code(child, item.haul_distance_m)
|
||||||
|
if wanted and wanted not in unit_prices.book.titles:
|
||||||
|
why = unit_prices.component_gaps.get(child) or "덤프 운반·적재 일위대가를 못 세웠습니다"
|
||||||
|
if why:
|
||||||
|
row.add_note("unit_price_krw", why)
|
||||||
|
result.missing.append(
|
||||||
|
{
|
||||||
|
"name": row.name,
|
||||||
|
"code": node.code,
|
||||||
|
"unit": row.unit,
|
||||||
|
"quantity": str(item.quantity),
|
||||||
|
"reason": why,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
price_code = wanted
|
||||||
|
if not loading:
|
||||||
|
row.spec = f"{row.spec} L={item.haul_distance_m}m".strip()
|
||||||
if price_code not in unit_prices.book.titles:
|
if price_code not in unit_prices.book.titles:
|
||||||
# B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다.
|
# B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다.
|
||||||
# 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다.
|
# 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다.
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""B09 원가계산 — **덤프트럭 운반** 시공능력 (산림사업 표준품셈 10-12 「2. 운반」 · 2026-09-14).
|
||||||
|
|
||||||
|
원문(고시 2025-82호 10-12-1·2·3 「2. 운반」)은 표가 아니라 **채워 넣는 식 서식**이라 공종 마스터에
|
||||||
|
안 실렸다. 값은 원문 본문에서 읽어 여기 한 곳에 둔다(식을 데이터로).
|
||||||
|
|
||||||
|
Qt = T / γt × L 덤프 1대 적재토량(㎥, 흐트러진 상태) · T 15ton
|
||||||
|
n = Qt / (q × K) 적재기계 싸이클 횟수 · q 버켓 0.7㎥
|
||||||
|
t1 = ㎝s × n / (60 × Es) 적재 대기(분) · ㎝s 20초
|
||||||
|
t2 = (D/V1 + D/V2) × 60 왕복(분) · D 운반거리 km · V1 5 · V2 6 km/hr
|
||||||
|
㎝t = t1 + t2 + t3 + t4 + t5 t3 적하 1.1 · t4 대기 0.9 · t5 덮개 0.5 (분)
|
||||||
|
Q = 60 × Qt × f × E / ㎝t ㎥/hr (자연상태) · f = 1/L
|
||||||
|
|
||||||
|
⚠ **원문이 서로 어긋나는 자리 — 고른 값과 버린 값을 사유에 나란히 둔다**(2026-09-14 브레인 판정).
|
||||||
|
사용자가 뒤집을 수 있어야 한다.
|
||||||
|
① n 의 Qt — 원문 표기 「10/(0.7×K)」(셋 다 10) ↔ **계산값 T/γt×L**(토사 10.26·암절취 8.44·
|
||||||
|
발파암 10.16). 원문이 `q` 한 글자를 적재토량·버켓용량 두 뜻으로 써 10 은 토사 어림수
|
||||||
|
복사로 봄.
|
||||||
|
② 발파암 운반 줄에 E 누락 — **0.9**(덤프 작업효율 · 같은 절 토사·암절취 0.9). 빼면 Q 를 못 구함.
|
||||||
|
④ Es(적재기계 작업효율 · t1) ≠ E0(적재 식) — **운반 줄 값 그대로**(토사 0.85 ·
|
||||||
|
암 0.35).
|
||||||
|
⚠ 운반거리 D 는 원문에 없다 — **설계자 값**(B08 유토곡선·사토장). 없으면 줄을 안 세우고
|
||||||
|
까닭(0 원 금지).
|
||||||
|
⚠ 적재(「1. 적재」 굴착기 Q1)는 **별도 줄**이다 — 이 식의 t1 은 덤프가 기다리는 시간이지
|
||||||
|
굴착기 품이 아님(흙깎기 9-3-2 ㎝ 20초·135° 는 깎아 옆에 둠 — 22초·180° 싣기와 다른 일).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from B09_Estimation.B09_Estimation_MachineProductivity import fix2
|
||||||
|
|
||||||
|
#: 「[주] 장비는 덤프트럭(15ton)을 적용한다」 — 기계 카탈로그 덤프트럭 15.
|
||||||
|
DUMP_TRUCK_CODE = "0602-0150"
|
||||||
|
DUMP_PARENT = "FP-10-12"
|
||||||
|
|
||||||
|
_TRUCK_TON = Decimal(15)
|
||||||
|
_BUCKET_M3 = Decimal("0.7")
|
||||||
|
_LOADER_CYCLE_SEC = Decimal(20)
|
||||||
|
_V_LOADED_KMH = Decimal(5)
|
||||||
|
_V_EMPTY_KMH = Decimal(6)
|
||||||
|
_T3_UNLOAD, _T4_WAIT, _T5_COVER = Decimal("1.1"), Decimal("0.9"), Decimal("0.5")
|
||||||
|
_SIXTY = Decimal(60)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DumpMaterial:
|
||||||
|
"""10-12 절 하나의 재료 값 — 원문 「2. 운반」 줄."""
|
||||||
|
|
||||||
|
work_item_code: str
|
||||||
|
label: str
|
||||||
|
unit_weight: Decimal # γt ton/㎥
|
||||||
|
loose_factor: Decimal # L 토량환산계수
|
||||||
|
bucket_factor: Decimal # K
|
||||||
|
loader_efficiency: Decimal # Es
|
||||||
|
truck_efficiency: Decimal # E
|
||||||
|
notes: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
_COMMON_NOTE = (
|
||||||
|
"n 의 Qt = 계산값 T/γt×L(원문 표기 「10/(0.7×K)」의 10 은 토사 어림수 복사로 봄 — 버림)"
|
||||||
|
)
|
||||||
|
_ES_NOTE = "Es = 운반 줄 값(적재 식의 E0 와 다른 기호)"
|
||||||
|
|
||||||
|
DUMP_MATERIALS: dict[str, DumpMaterial] = {
|
||||||
|
"FP-10-12-01": DumpMaterial(
|
||||||
|
"FP-10-12-01", "토사", Decimal("1.9"), Decimal("1.3"), Decimal("0.9"), Decimal("0.85"),
|
||||||
|
Decimal("0.9"), (_COMMON_NOTE, _ES_NOTE),
|
||||||
|
),
|
||||||
|
"FP-10-12-02": DumpMaterial(
|
||||||
|
"FP-10-12-02", "암절취", Decimal("2.4"), Decimal("1.35"), Decimal("0.55"), Decimal("0.35"),
|
||||||
|
Decimal("0.9"), (_COMMON_NOTE + " · 원문 10 이면 n 1.18배", _ES_NOTE),
|
||||||
|
),
|
||||||
|
"FP-10-12-03": DumpMaterial(
|
||||||
|
"FP-10-12-03", "발파암", Decimal("2.4"), Decimal("1.625"), Decimal("0.55"), Decimal("0.35"),
|
||||||
|
Decimal("0.9"),
|
||||||
|
(_COMMON_NOTE, _ES_NOTE, "E 원문 누락 — 0.9 적용(같은 절 토사·암절취 · 덤프 작업효율)"),
|
||||||
|
),
|
||||||
|
} # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
#: 「1. 적재」 — Q1 = 3600 × q0 × K × f × E0 / ㎝ · 굴착기(무한궤도) 0.7㎥ · ㎝ 22초(180°).
|
||||||
|
#: ⚠ E0 는 운반 식의 Es 와 **다른 기호**다(판정 ④⑥). 토사 본문 「E0=0.75」 는 표(10-12-1 [주]⑤
|
||||||
|
#: 「E0 토사 0.60(불량) — 임도」)·10-12-3 [주]③(「토사 0.6」)과 어긋나 **0.60 채택 · 0.75 버림**.
|
||||||
|
LOADER_CODE = "0201-0070"
|
||||||
|
_LOADING_CYCLE_SEC = Decimal(22)
|
||||||
|
_LOADING_E0 = {
|
||||||
|
"FP-10-12-01": Decimal("0.60"),
|
||||||
|
"FP-10-12-02": Decimal("0.35"),
|
||||||
|
"FP-10-12-03": Decimal("0.35"),
|
||||||
|
}
|
||||||
|
_LOADING_NOTES = {
|
||||||
|
"FP-10-12-01": "E0 = 0.60(표 「임도」·10-12-3 [주]③) — 본문 표기 0.75 버림",
|
||||||
|
"FP-10-12-02": "E0 = 0.35(본문·표 「파쇄암」 같음)",
|
||||||
|
"FP-10-12-03": "E0 = 0.35(10-12-3 [주]③ 「파쇄암 0.35」)",
|
||||||
|
} # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def loading_output(code: str) -> Decimal:
|
||||||
|
"""적재 Q1 (㎥/hr, 자연상태) — f·Q1 소수 2자리 확정(명세 7장)."""
|
||||||
|
material = DUMP_MATERIALS[code]
|
||||||
|
return fix2(
|
||||||
|
Decimal(3600)
|
||||||
|
* _BUCKET_M3
|
||||||
|
* material.bucket_factor
|
||||||
|
* fix2(Decimal(1) / material.loose_factor)
|
||||||
|
* _LOADING_E0[code]
|
||||||
|
/ _LOADING_CYCLE_SEC
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def loading_title_code(code: str) -> str:
|
||||||
|
return f"B-{code}#적재"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DumpHaul:
|
||||||
|
"""덤프 운반 한 벌 — 재료 + 운반거리."""
|
||||||
|
|
||||||
|
material: DumpMaterial
|
||||||
|
distance_m: Decimal
|
||||||
|
|
||||||
|
@property
|
||||||
|
def truck_load_m3(self) -> Decimal:
|
||||||
|
return _TRUCK_TON / self.material.unit_weight * self.material.loose_factor
|
||||||
|
|
||||||
|
@property
|
||||||
|
def loader_cycles(self) -> Decimal:
|
||||||
|
return self.truck_load_m3 / (_BUCKET_M3 * self.material.bucket_factor)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cycle_minutes(self) -> Decimal:
|
||||||
|
wait = _LOADER_CYCLE_SEC * self.loader_cycles / (_SIXTY * self.material.loader_efficiency)
|
||||||
|
km = self.distance_m / Decimal(1000)
|
||||||
|
travel = (km / _V_LOADED_KMH + km / _V_EMPTY_KMH) * _SIXTY
|
||||||
|
return wait + travel + _T3_UNLOAD + _T4_WAIT + _T5_COVER
|
||||||
|
|
||||||
|
@property
|
||||||
|
def volume_factor(self) -> Decimal:
|
||||||
|
return fix2(Decimal(1) / self.material.loose_factor)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hourly_output(self) -> Decimal:
|
||||||
|
"""Q (㎥/hr) — f 와 Q 는 소수 2자리로 먼저 확정(명세 7장)."""
|
||||||
|
return fix2(
|
||||||
|
_SIXTY
|
||||||
|
* self.truck_load_m3
|
||||||
|
* self.volume_factor
|
||||||
|
* self.material.truck_efficiency
|
||||||
|
/ self.cycle_minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def formula_text(self) -> str:
|
||||||
|
m = self.material
|
||||||
|
return (
|
||||||
|
f"Qt = 15/{m.unit_weight}×{m.loose_factor} = {self.truck_load_m3:.2f}㎥ · "
|
||||||
|
f"n = {self.truck_load_m3:.2f}/(0.7×{m.bucket_factor}) = {self.loader_cycles:.2f}회 · "
|
||||||
|
f"㎝t = {self.cycle_minutes:.2f}분(L={self.distance_m}m) · "
|
||||||
|
f"Q = 60×{self.truck_load_m3:.2f}×{self.volume_factor}×{m.truck_efficiency}"
|
||||||
|
f"/{self.cycle_minutes:.2f} = {self.hourly_output}㎥/hr"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dump_title_code(work_item_code: str, distance_m: Decimal) -> str:
|
||||||
|
"""운반거리별 갈래 — 사토장이 여럿이면 거리마다 호표가 갈림(STmate 도 그 모양)."""
|
||||||
|
return f"B-{work_item_code}#L{distance_m.normalize():f}m"
|
||||||
|
|
||||||
|
|
||||||
|
def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Decimal, ...]) -> None:
|
||||||
|
"""거리마다 덤프 운반 일위대가를 세운다 — X(덤프트럭 15ton) → D → B.
|
||||||
|
|
||||||
|
시간당 사용료가 안 섰으면 세우지 않고 까닭을 남긴다(0 원 일위대가 금지).
|
||||||
|
"""
|
||||||
|
from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle
|
||||||
|
|
||||||
|
names = {
|
||||||
|
str(node.get("work_item_code")): str(node.get("name") or "")
|
||||||
|
for node in master.get("work_items", [])
|
||||||
|
}
|
||||||
|
# 적재 — 거리와 무관해 늘 세움(굴착기 0.7㎥ 사용료 층이 있으면). 운반과 한 벌이라 여기 둠.
|
||||||
|
loader = f"X-{LOADER_CODE}"
|
||||||
|
for code in DUMP_MATERIALS:
|
||||||
|
title_code = loading_title_code(code)
|
||||||
|
if loader not in build.book.titles or title_code in build.book.titles:
|
||||||
|
continue
|
||||||
|
output = loading_output(code)
|
||||||
|
material = DUMP_MATERIALS[code]
|
||||||
|
label = names.get(code) or material.label
|
||||||
|
build.book.add_title(
|
||||||
|
PriceTitle(
|
||||||
|
code=title_code,
|
||||||
|
kind=PriceKind.UNIT_PRICE,
|
||||||
|
name=f"{names.get(DUMP_PARENT) or '덤프운반'} {label} 적재",
|
||||||
|
spec="굴착기(무한궤도) 0.7㎥",
|
||||||
|
unit="㎥",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
build.book.add_output_detail(
|
||||||
|
title_code,
|
||||||
|
loader,
|
||||||
|
Decimal(1) / output,
|
||||||
|
f"Q1 = 3600×0.7×{material.bucket_factor}×{fix2(Decimal(1) / material.loose_factor)}"
|
||||||
|
f"×{_LOADING_E0[code]}/22 = {output}㎥/hr · {_LOADING_NOTES[code]}",
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
if not distances_m:
|
||||||
|
return
|
||||||
|
hourly = f"X-{DUMP_TRUCK_CODE}"
|
||||||
|
for code, material in DUMP_MATERIALS.items():
|
||||||
|
if hourly not in build.book.titles:
|
||||||
|
build.component_gaps[code] = "덤프트럭(15ton) 시간당 사용료가 아직 안 섰습니다"
|
||||||
|
continue
|
||||||
|
for distance in distances_m:
|
||||||
|
haul = DumpHaul(material, distance)
|
||||||
|
title_code = dump_title_code(code, distance)
|
||||||
|
if title_code in build.book.titles:
|
||||||
|
continue
|
||||||
|
build.book.add_title(
|
||||||
|
PriceTitle(
|
||||||
|
code=title_code,
|
||||||
|
kind=PriceKind.UNIT_PRICE,
|
||||||
|
name=f"{names.get(DUMP_PARENT) or '덤프운반'} "
|
||||||
|
f"{names.get(code) or material.label}",
|
||||||
|
spec=f"L={distance.normalize():f}m",
|
||||||
|
unit="㎥",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
build.book.add_output_detail(
|
||||||
|
title_code,
|
||||||
|
hourly,
|
||||||
|
Decimal(1) / haul.hourly_output,
|
||||||
|
haul.formula_text + " · " + " · ".join(material.notes),
|
||||||
|
output=haul.hourly_output,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dump_haul_distances(payload: dict[str, Any]) -> tuple[str, ...]:
|
||||||
|
"""B08 인계에서 덤프 운반 거리(m) 갈래 — 거리가 없는 줄은 안 셈(그 줄은 「운반거리 미입력」)."""
|
||||||
|
found = {
|
||||||
|
str(Decimal(str(row["haul_distance_m"])))
|
||||||
|
for row in payload.get("work_items") or []
|
||||||
|
if str(row.get("work_item_code") or "").startswith(DUMP_PARENT)
|
||||||
|
and row.get("haul_equipment") == "dump_truck"
|
||||||
|
and row.get("haul_distance_m") not in (None, "")
|
||||||
|
}
|
||||||
|
return tuple(sorted(found, key=Decimal))
|
||||||
|
|
||||||
|
|
||||||
|
_RE_DUMP_CODE = re.compile(rf"^[BD]-{DUMP_PARENT}-0[1-3]#L(\d+(?:\.\d+)?)m$")
|
||||||
|
|
||||||
|
#: 인계 「덤프 적재」 짝 줄의 수단 표지 — 운반 줄(`dump_truck`)과 갈라 잎 `#적재` 로 감.
|
||||||
|
LOADING_EQUIPMENT = "dump_loading"
|
||||||
|
|
||||||
|
|
||||||
|
def dump_distance_from_code(code: str) -> tuple[str, ...]:
|
||||||
|
"""호표 한 장을 여는 쪽 — 코드 `#L164.23m` 에서 거리를 되읽음(인계를 다시 안 셈)."""
|
||||||
|
found = _RE_DUMP_CODE.match(code)
|
||||||
|
return (found.group(1),) if found else ()
|
||||||
|
|
||||||
|
|
||||||
|
def dump_child_for(ground: str | None, edition: str) -> str | None:
|
||||||
|
"""인계 갈래(토사·리핑암·발파암) → 10-12 잎. 모르는 갈래는 `None` — 가까운 것을 안 고름."""
|
||||||
|
from common_util.common_util_aliases import alias_target, load_aliases
|
||||||
|
|
||||||
|
wanted = str(ground or "").strip()
|
||||||
|
if not wanted:
|
||||||
|
return None
|
||||||
|
alias = alias_target(load_aliases("variant"), wanted, DUMP_PARENT, edition) or wanted
|
||||||
|
return next(
|
||||||
|
(code for code, material in DUMP_MATERIALS.items() if material.label == alias), None
|
||||||
|
)
|
||||||
@@ -31,6 +31,10 @@ from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
|
|||||||
from B09_Estimation.B09_Estimation_Provenance import estimation_provenance
|
from B09_Estimation.B09_Estimation_Provenance import estimation_provenance
|
||||||
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill
|
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill
|
||||||
from B09_Estimation.B09_Estimation_Guards import DoubleCountError
|
from B09_Estimation.B09_Estimation_Guards import DoubleCountError
|
||||||
|
from B09_Estimation.B09_Estimation_MachineProductivity_Dump import (
|
||||||
|
dump_distance_from_code,
|
||||||
|
dump_haul_distances,
|
||||||
|
)
|
||||||
from B09_Estimation.B09_Estimation_Rates import RateLookupError
|
from B09_Estimation.B09_Estimation_Rates import RateLookupError
|
||||||
from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS
|
from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS
|
||||||
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
||||||
@@ -248,11 +252,13 @@ async def _project_root_of(project_id: UUID) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _build_for(project_id: UUID):
|
async def _build_for(project_id: UUID, dump_haul_m: tuple[str, ...] = ()):
|
||||||
"""그 프로젝트가 **고른 값**으로 조립한 일위대가.
|
"""그 프로젝트가 **고른 값**으로 조립한 일위대가.
|
||||||
|
|
||||||
⚠ 범위 계수(작업효율)·장비 규격은 프로젝트마다 다를 수 있다(확정 ①). 전역 한 벌로
|
⚠ 범위 계수(작업효율)·장비 규격은 프로젝트마다 다를 수 있다(확정 ①). 전역 한 벌로
|
||||||
돌면 한 프로젝트에서 바꾼 값이 다른 프로젝트 금액까지 흔든다.
|
돌면 한 프로젝트에서 바꾼 값이 다른 프로젝트 금액까지 흔든다.
|
||||||
|
`dump_haul_m` — 덤프 운반 거리(m) 갈래. 인계(유토곡선·사토장)에서 오므로 내역이 넘기고,
|
||||||
|
호표 한 장을 여는 쪽은 코드(`#L…m`)에서 되읽어 넘김.
|
||||||
"""
|
"""
|
||||||
from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices as parse_labor_surcharge
|
from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices as parse_labor_surcharge
|
||||||
from common_util.common_util_project_settings import estimation_settings
|
from common_util.common_util_project_settings import estimation_settings
|
||||||
@@ -281,6 +287,7 @@ async def _build_for(project_id: UUID):
|
|||||||
tuple(sorted(parse_labor_surcharge(settings.get("labor_surcharge")).items())),
|
tuple(sorted(parse_labor_surcharge(settings.get("labor_surcharge")).items())),
|
||||||
# 조종원 시간당 노임 자르는 자리 — 실무마다 다름(명세 7장 정정). 안 정하면 원 미만.
|
# 조종원 시간당 노임 자르는 자리 — 실무마다 다름(명세 7장 정정). 안 정하면 원 미만.
|
||||||
str(settings.get("operator_wage_digits") or ""),
|
str(settings.get("operator_wage_digits") or ""),
|
||||||
|
tuple(sorted(set(dump_haul_m), key=Decimal)),
|
||||||
)
|
)
|
||||||
# 사용자가 고친 값(PLAN 12장 2차) — 없으면 기본 조립 그 벌 그대로.
|
# 사용자가 고친 값(PLAN 12장 2차) — 없으면 기본 조립 그 벌 그대로.
|
||||||
return edited_build(args, edits_key(settings.get("edits")))
|
return edited_build(args, edits_key(settings.get("edits")))
|
||||||
@@ -415,7 +422,10 @@ async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse:
|
|||||||
try:
|
try:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=_with_provenance(
|
content=_with_provenance(
|
||||||
{"status": "success", **detail_of(await _build_for(project_id), code)}
|
{
|
||||||
|
"status": "success",
|
||||||
|
**detail_of(await _build_for(project_id, dump_distance_from_code(code)), code),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except PriceBookError as error:
|
except PriceBookError as error:
|
||||||
@@ -460,11 +470,12 @@ async def get_bill(project_id: UUID) -> JSONResponse:
|
|||||||
from B08_Quantity.B08_Quantity_Router_Material import get_handoff, structure_bill_prices
|
from B08_Quantity.B08_Quantity_Router_Material import get_handoff, structure_bill_prices
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# ⚠ **그 프로젝트가 고른 값**으로 조립한 단가표 — 구조물도 미리보기(`_build_for`)와 한 벌.
|
|
||||||
# 기본값(`cached_build()`)을 쓰면 계수를 고른 프로젝트에서 화면과 내역이 갈림(브레인).
|
|
||||||
build = await _build_for(project_id)
|
|
||||||
response = await get_handoff(project_id)
|
response = await get_handoff(project_id)
|
||||||
payload = json.loads(bytes(response.body).decode("utf-8"))
|
payload = json.loads(bytes(response.body).decode("utf-8"))
|
||||||
|
# ⚠ **그 프로젝트가 고른 값**으로 조립한 단가표 — 구조물도 미리보기(`_build_for`)와 한 벌.
|
||||||
|
# 기본값(`cached_build()`)을 쓰면 계수를 고른 프로젝트에서 화면과 내역이 갈림(브레인).
|
||||||
|
# 덤프 운반은 인계 거리마다 갈래가 서므로 인계를 먼저 받음(10-12 · 2026-09-14).
|
||||||
|
build = await _build_for(project_id, dump_haul_distances(payload))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("B09 내역서 조회 실패(인계): project_id=%s", project_id)
|
logger.exception("B09 내역서 조회 실패(인계): project_id=%s", project_id)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -535,7 +546,7 @@ async def get_price_basis_detail(project_id: UUID, code: str) -> JSONResponse:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# 내역서와 같은 단가표 — 번호·금액이 내역 줄과 갈리지 않게.
|
# 내역서와 같은 단가표 — 번호·금액이 내역 줄과 갈리지 않게.
|
||||||
body = price_basis_detail(code, await _build_for(project_id))
|
body = price_basis_detail(code, await _build_for(project_id, dump_distance_from_code(code)))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("B09 단가산출서 조회 실패: project_id=%s, code=%s", project_id, code)
|
logger.exception("B09 단가산출서 조회 실패: project_id=%s, code=%s", project_id, code)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
@@ -617,6 +617,7 @@ def build_unit_prices(
|
|||||||
transport_road: str | None = None,
|
transport_road: str | None = None,
|
||||||
labor_surcharge_choices: dict[str, str] | None = None,
|
labor_surcharge_choices: dict[str, str] | None = None,
|
||||||
operator_wage_digits: int = 0,
|
operator_wage_digits: int = 0,
|
||||||
|
dump_haul_m: tuple[Decimal, ...] = (),
|
||||||
) -> UnitPriceBuild:
|
) -> UnitPriceBuild:
|
||||||
"""자원 축을 일위대가(`B`)로 조립한다.
|
"""자원 축을 일위대가(`B`)로 조립한다.
|
||||||
|
|
||||||
@@ -771,6 +772,14 @@ def build_unit_prices(
|
|||||||
from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS
|
from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS
|
||||||
|
|
||||||
machine_codes |= {variant["machine_code"] for variant in TRANSPORT_VARIANTS}
|
machine_codes |= {variant["machine_code"] for variant in TRANSPORT_VARIANTS}
|
||||||
|
# 덤프 운반(10-12)도 공식표가 아니라 본문 식이라 기종이 안 드러남 — **거리가 왔을 때만** 세운다.
|
||||||
|
from B09_Estimation.B09_Estimation_MachineProductivity_Dump import (
|
||||||
|
DUMP_TRUCK_CODE,
|
||||||
|
attach_dump_hauls,
|
||||||
|
)
|
||||||
|
|
||||||
|
if dump_haul_m:
|
||||||
|
machine_codes.add(DUMP_TRUCK_CODE)
|
||||||
build.operator_wage_digits = operator_wage_digits
|
build.operator_wage_digits = operator_wage_digits
|
||||||
build.incomplete_machines = _add_machine_layers(
|
build.incomplete_machines = _add_machine_layers(
|
||||||
build.book, machine_codes, fuel_region, operator_wage_digits
|
build.book, machine_codes, fuel_region, operator_wage_digits
|
||||||
@@ -1022,6 +1031,8 @@ def build_unit_prices(
|
|||||||
distance_km=transport_distance_km,
|
distance_km=transport_distance_km,
|
||||||
road_key=transport_road,
|
road_key=transport_road,
|
||||||
)
|
)
|
||||||
|
# 덤프 운반 — 운반거리(B08 유토곡선·사토장)마다 한 벌(산림품셈 10-12 「2. 운반」).
|
||||||
|
attach_dump_hauls(build, master, dump_haul_m)
|
||||||
|
|
||||||
# ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다
|
# ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다
|
||||||
# (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다.
|
# (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다.
|
||||||
@@ -1161,6 +1172,7 @@ def cached_build(
|
|||||||
transport_road: str = "",
|
transport_road: str = "",
|
||||||
labor_surcharge: tuple[tuple[str, str], ...] = (),
|
labor_surcharge: tuple[tuple[str, str], ...] = (),
|
||||||
operator_wage_digits: str = "",
|
operator_wage_digits: str = "",
|
||||||
|
dump_haul_m: tuple[str, ...] = (),
|
||||||
) -> UnitPriceBuild:
|
) -> UnitPriceBuild:
|
||||||
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.
|
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.
|
||||||
|
|
||||||
@@ -1188,6 +1200,7 @@ def cached_build(
|
|||||||
transport_road=transport_road or None,
|
transport_road=transport_road or None,
|
||||||
labor_surcharge_choices=dict(labor_surcharge),
|
labor_surcharge_choices=dict(labor_surcharge),
|
||||||
operator_wage_digits=parse_operator_wage_digits(operator_wage_digits),
|
operator_wage_digits=parse_operator_wage_digits(operator_wage_digits),
|
||||||
|
dump_haul_m=tuple(Decimal(value) for value in dump_haul_m),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""덤프 운반(산림품셈 10-12 「2. 운반」) — 거리별 호표 · 원문 어긋남은 고른 값·버린 값을 사유에.
|
||||||
|
|
||||||
|
2026-09-14 축 C 1장 (브레인 판정 ①②④⑤):
|
||||||
|
① n 의 Qt = 계산값 T/γt×L (원문 표기 10 버림) ② 발파암 E 0.9 ④ Es = 운반 줄 값
|
||||||
|
⑤ 운반거리마다 갈래 · 거리 없으면 0 원 아니라 「운반거리 미입력」
|
||||||
|
손셈(토사 164.23 m): Qt 10.2632 · n 16.2907 · t1 6.3885 · t2 3.6131 · ㎝t 12.5016 · f 0.77
|
||||||
|
→ Q 34.135 → 34.14(사사오입)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from B09_Estimation.B09_Estimation_MachineProductivity_Dump import ( # noqa: E402
|
||||||
|
DUMP_MATERIALS,
|
||||||
|
DumpHaul,
|
||||||
|
dump_child_for,
|
||||||
|
dump_distance_from_code,
|
||||||
|
dump_haul_distances,
|
||||||
|
dump_title_code,
|
||||||
|
)
|
||||||
|
from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402
|
||||||
|
|
||||||
|
DISTANCE = Decimal("164.23")
|
||||||
|
|
||||||
|
|
||||||
|
def test_토사_운반_식_손셈() -> None:
|
||||||
|
haul = DumpHaul(DUMP_MATERIALS["FP-10-12-01"], DISTANCE)
|
||||||
|
assert round(haul.truck_load_m3, 4) == Decimal("10.2632")
|
||||||
|
assert round(haul.loader_cycles, 4) == Decimal("16.2907")
|
||||||
|
assert round(haul.cycle_minutes, 4) == Decimal("12.5016")
|
||||||
|
assert (haul.volume_factor, haul.hourly_output) == (Decimal("0.77"), Decimal("34.14"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_암절취는_계산값_Qt_와_운반줄_Es() -> None:
|
||||||
|
haul = DumpHaul(DUMP_MATERIALS["FP-10-12-02"], DISTANCE)
|
||||||
|
assert round(haul.truck_load_m3, 4) == Decimal("8.4375") # 원문 표기 10 이 아님
|
||||||
|
assert haul.hourly_output == Decimal("12.49")
|
||||||
|
assert any("원문 표기" in note for note in DUMP_MATERIALS["FP-10-12-02"].notes)
|
||||||
|
assert DUMP_MATERIALS["FP-10-12-03"].truck_efficiency == Decimal("0.9")
|
||||||
|
assert any("E 원문 누락" in note for note in DUMP_MATERIALS["FP-10-12-03"].notes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_거리마다_호표가_서고_코드에서_거리를_되읽는다() -> None:
|
||||||
|
build = cached_build(dump_haul_m=(str(DISTANCE),))
|
||||||
|
code = dump_title_code("FP-10-12-01", DISTANCE)
|
||||||
|
assert code == "B-FP-10-12-01#L164.23m"
|
||||||
|
money = build.book.resolve(code)
|
||||||
|
assert money.total > 0
|
||||||
|
assert dump_distance_from_code(code) == ("164.23",)
|
||||||
|
assert code not in cached_build().book.titles # 거리 없는 조립엔 안 섬
|
||||||
|
|
||||||
|
|
||||||
|
def test_인계_거리와_갈래_잇기() -> None:
|
||||||
|
payload = {
|
||||||
|
"work_items": [
|
||||||
|
{
|
||||||
|
"work_item_code": "FP-10-12",
|
||||||
|
"haul_equipment": "dump_truck",
|
||||||
|
"haul_distance_m": 164.23,
|
||||||
|
},
|
||||||
|
{"work_item_code": "FP-10-12", "haul_equipment": "dump_truck", "haul_distance_m": None},
|
||||||
|
{"work_item_code": "FP-10-11", "haul_equipment": "dozer", "haul_distance_m": 32.09},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert dump_haul_distances(payload) == ("164.23",)
|
||||||
|
assert dump_child_for("리핑암", "2026-01-01") == "FP-10-12-02" # 범위 별칭 리핑암 → 암절취
|
||||||
|
assert dump_child_for("토사", "2026-01-01") == "FP-10-12-01"
|
||||||
|
assert dump_child_for("모르는암", "2026-01-01") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_적재는_거리_무관_한_장이고_E0_는_0_60() -> None:
|
||||||
|
from B09_Estimation.B09_Estimation_MachineProductivity_Dump import (
|
||||||
|
loading_output,
|
||||||
|
loading_title_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 토사 3600×0.7×0.9×0.77×0.60/22 = 47.628 → 47.63
|
||||||
|
# 암절취 3600×0.7×0.55×0.74×0.35/22 = 16.317 → 16.32
|
||||||
|
assert loading_output("FP-10-12-01") == Decimal("47.63")
|
||||||
|
assert loading_output("FP-10-12-02") == Decimal("16.32")
|
||||||
|
build = cached_build() # 거리 없어도 섬
|
||||||
|
code = loading_title_code("FP-10-12-01")
|
||||||
|
assert build.book.resolve(code).total > 0
|
||||||
|
basis = build.book.details[f"D-{code[2:]}"][0].note
|
||||||
|
assert "0.75 버림" in basis
|
||||||
|
|
||||||
|
|
||||||
|
def test_내역_줄_거리_없으면_운반거리_미입력() -> None:
|
||||||
|
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
|
||||||
|
|
||||||
|
def payload(distance):
|
||||||
|
return {
|
||||||
|
"work_items": [
|
||||||
|
{
|
||||||
|
"work_item_code": "FP-10-12",
|
||||||
|
"name": "dump_truck 운반",
|
||||||
|
"spec": "토사",
|
||||||
|
"unit": "㎥",
|
||||||
|
"quantity": 40.0,
|
||||||
|
"haul_equipment": "dump_truck",
|
||||||
|
"haul_distance_m": distance,
|
||||||
|
"variant_axis": "ground_class",
|
||||||
|
"variant_value": "토사",
|
||||||
|
"in_bill": True,
|
||||||
|
"origin": "haul",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"materials": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
blank = build_bill(payload(None), build=cached_build())
|
||||||
|
assert any("운반거리 미입력" in m["reason"] for m in blank.missing)
|
||||||
|
priced = build_bill(payload(164.23), build=cached_build(dump_haul_m=("164.23",)))
|
||||||
|
row = next(r for r in priced.rows if r.name == "dump_truck 운반")
|
||||||
|
assert row.price_code == "B-FP-10-12-01#L164.23m" and row.amount_krw > 0
|
||||||
|
loading = payload(None)
|
||||||
|
loading["work_items"][0].update(name="덤프 적재", haul_equipment="dump_loading")
|
||||||
|
loaded = build_bill(loading, build=cached_build())
|
||||||
|
row = next(r for r in loaded.rows if r.name == "덤프 적재")
|
||||||
|
assert row.price_code == "B-FP-10-12-01#적재" and row.amount_krw > 0
|
||||||
Reference in New Issue
Block a user