Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28a5bcb636 | ||
|
|
389d014018 | ||
|
|
3c4b915f7e | ||
|
|
b6fb372543 | ||
|
|
028a6be247 |
@@ -0,0 +1,68 @@
|
||||
"""B09 — 기계 작업량 **밑값**을 마스터에서 읽는다(2026-09-16 사용자 원칙 · 브레인 지시).
|
||||
|
||||
마스터 = 관리자가 제어할 값 · **코드 안에 있으면 안 됨.**
|
||||
|
||||
그동안 불도저 속도·삽날 용량(건설품셈 8-2-1)과 덤프 운반·적재 식 계수(산림품셈 10-12)가
|
||||
코드 상수로 박혀 있었다. 값은 그대로 두고 자리만 옮긴다 —
|
||||
`resources/data_machine_productivity/machine_productivity_<판>.json`.
|
||||
⚠ **식은 여기 없다.** `Q = n·q·f·E` 같은 식은 값이 아니라 로직이라 계산 모듈에 남는다.
|
||||
⚠ 옮기면서 값을 하나도 안 바꿨다 — 원문(pum_const C0426·C0427)과의 대조는 시험이 지킨다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parent.parent / "resources" / "data_machine_productivity"
|
||||
PREFIX = "machine_productivity_"
|
||||
|
||||
|
||||
class MachineProductivityDataError(FileNotFoundError):
|
||||
"""밑값 판이 없다 — 지어내지 않고 멈춘다."""
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _read(path: str, _mtime_ns: int) -> dict[str, Any]:
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def load() -> dict[str, Any]:
|
||||
"""끝 판 한 벌(이름 차례)."""
|
||||
found = sorted(DATA_DIR.glob(PREFIX + "*.json")) if DATA_DIR.is_dir() else []
|
||||
if not found:
|
||||
raise MachineProductivityDataError(f"기계 작업량 밑값 판이 없습니다: {DATA_DIR}")
|
||||
return _read(str(found[-1]), found[-1].stat().st_mtime_ns)
|
||||
|
||||
|
||||
def records(variable: str) -> list[dict[str, Any]]:
|
||||
return load()["variables"][variable]["records"]
|
||||
|
||||
|
||||
def dozer_speeds() -> dict[str, dict[Decimal, dict[int, tuple[Decimal, Decimal]]]]:
|
||||
"""{무한궤도·타이어: {규격(ton): {단: (전진, 후진)}}} — 후진이 원문에 없는 단은 담기지 않는다."""
|
||||
out: dict[str, dict[Decimal, dict[int, tuple[Decimal, Decimal]]]] = {}
|
||||
for row in records("dozer_speed"):
|
||||
track = out.setdefault(row["track"], {})
|
||||
gears = track.setdefault(Decimal(row["tonnage_ton"]), {})
|
||||
gears[int(row["gear"])] = (
|
||||
Decimal(row["forward_m_per_min"]),
|
||||
Decimal(row["reverse_m_per_min"]),
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def dozer_blades() -> dict[tuple[str, Decimal], Decimal]:
|
||||
"""{(무한궤도·타이어, 규격(ton)): 삽날 용량 q˚(㎥)}."""
|
||||
return {
|
||||
(row["track"], Decimal(row["tonnage_ton"])): Decimal(row["blade_m3"])
|
||||
for row in records("dozer_blade")
|
||||
}
|
||||
|
||||
|
||||
def haul_params() -> dict[str, Decimal]:
|
||||
"""덤프 운반·적재 식이 쓰는 밑값(적재 규격 T · 버켓 q · 싸이클 · 주행속도 · 적하·대기·덮개)."""
|
||||
return {row["key"]: Decimal(row["value"]) for row in records("dump_haul")}
|
||||
@@ -23,6 +23,7 @@ from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation import B09_Estimation_MachineProductivity_Data as data
|
||||
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
|
||||
from B09_Estimation.B09_Estimation_MachineProductivity import (
|
||||
CycleFactors,
|
||||
@@ -38,47 +39,13 @@ _ZERO = Decimal(0)
|
||||
|
||||
|
||||
#: 전진·후진 속도 (m/분) — 8-2-1 2.가·나. **단(gear)마다 다르다.**
|
||||
#: ⭐ 값은 마스터 `resources/data_machine_productivity/` 에 있다 — 여기 박지 않는다(사용자 원칙).
|
||||
#: {규격(ton): {단: (전진, 후진)}}
|
||||
#: ⚠ 무한궤도 4·13 톤과 타이어 전 규격은 **후진 3단이 표에 없다**(「-」) — 그 단은
|
||||
#: 아예 안 담는다. 담으면 없는 속도로 싸이클이 서서 값이 조용히 틀린다.
|
||||
#: ⚠ **단은 작업이 정한다** (8-2-1 2.가 [주]) — 굴착·굴착운반 1단, 흐트러진 토사운반
|
||||
#: 2단, 평탄 정지·전압 3단. 그래서 임도 표가 「55m/분(2단)」처럼 단을 적어 온다.
|
||||
_DOZER_SPEEDS = {
|
||||
"무한궤도": {
|
||||
Decimal("4"): {1: (Decimal(40), Decimal(63)), 2: (Decimal(57), Decimal(85))},
|
||||
Decimal("7"): {
|
||||
1: (Decimal(43), Decimal(53)),
|
||||
2: (Decimal(67), Decimal(78)),
|
||||
3: (Decimal(92), Decimal(107)),
|
||||
},
|
||||
Decimal("10"): {
|
||||
1: (Decimal(42), Decimal(50)),
|
||||
2: (Decimal(64), Decimal(75)),
|
||||
3: (Decimal(88), Decimal(105)),
|
||||
},
|
||||
Decimal("12"): {
|
||||
1: (Decimal(40), Decimal(48)),
|
||||
2: (Decimal(55), Decimal(70)),
|
||||
3: (Decimal(75), Decimal(100)),
|
||||
},
|
||||
Decimal("13"): {1: (Decimal(40), Decimal(48)), 2: (Decimal(55), Decimal(70))},
|
||||
Decimal("19"): {
|
||||
1: (Decimal(40), Decimal(46)),
|
||||
2: (Decimal(55), Decimal(70)),
|
||||
3: (Decimal(75), Decimal(98)),
|
||||
},
|
||||
Decimal("32"): {
|
||||
1: (Decimal(40), Decimal(43)),
|
||||
2: (Decimal(52), Decimal(58)),
|
||||
3: (Decimal(70), Decimal(78)),
|
||||
},
|
||||
},
|
||||
"타이어": {
|
||||
Decimal("15"): {1: (Decimal(83), Decimal(92)), 2: (Decimal(200), Decimal(125))},
|
||||
Decimal("28"): {1: (Decimal(92), Decimal(92)), 2: (Decimal(200), Decimal(200))},
|
||||
Decimal("33"): {1: (Decimal(92), Decimal(110)), 2: (Decimal(210), Decimal(250))},
|
||||
},
|
||||
}
|
||||
_DOZER_SPEEDS = data.dozer_speeds() # 마스터에서 읽음 — 값은 그대로(2026-09-16 꺼냄)
|
||||
#: 기어 변속시간 (분) — 8-2-1 「t: 기어 변속시간(0.25분)」
|
||||
_DOZER_GEAR_SHIFT_MIN = Decimal("0.25")
|
||||
_MINUTES_PER_HOUR = Decimal(60)
|
||||
@@ -144,18 +111,7 @@ def dozer_speeds(
|
||||
|
||||
#: 삽날 용량 q゚(㎥) — 8-2-1 1.가. **규격을 되짚는 열쇠**로도 쓴다.
|
||||
#: ⚠ 무한궤도 10 톤과 13 톤이 둘 다 1.5 ㎥ 라 **용량만으로는 못 가른다** — 속도로 마저 가른다.
|
||||
_DOZER_BLADE_M3 = {
|
||||
("무한궤도", Decimal("4")): Decimal("0.5"), # 초습지
|
||||
("무한궤도", Decimal("7")): Decimal("1.1"),
|
||||
("무한궤도", Decimal("10")): Decimal("1.5"),
|
||||
("무한궤도", Decimal("12")): Decimal("2.0"),
|
||||
("무한궤도", Decimal("13")): Decimal("1.5"), # 습지
|
||||
("무한궤도", Decimal("19")): Decimal("3.2"),
|
||||
("무한궤도", Decimal("32")): Decimal("5.5"),
|
||||
("타이어", Decimal("15")): Decimal("3.1"),
|
||||
("타이어", Decimal("28")): Decimal("4.0"),
|
||||
("타이어", Decimal("33")): Decimal("5.7"),
|
||||
}
|
||||
_DOZER_BLADE_M3 = data.dozer_blades() # 마스터에서 읽음
|
||||
|
||||
#: 습지·초습지 갈래는 카탈로그 이름이 따로다 — 「습지 불도저」.
|
||||
_DOZER_WET_TONS = (Decimal("4"), Decimal("13"))
|
||||
|
||||
@@ -31,19 +31,23 @@ from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation import B09_Estimation_MachineProductivity_Data as data
|
||||
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)
|
||||
#: ⭐ 식이 쓰는 밑값은 **마스터**에 있다 — `resources/data_machine_productivity/`(2026-09-16 꺼냄).
|
||||
#: 식(Q·㎝t·n)은 값이 아니라 로직이라 여기 남는다.
|
||||
_HAUL = data.haul_params()
|
||||
_TRUCK_TON = _HAUL["truck_ton"]
|
||||
_BUCKET_M3 = _HAUL["bucket_m3"]
|
||||
_LOADER_CYCLE_SEC = _HAUL["loader_cycle_sec"]
|
||||
_V_LOADED_KMH = _HAUL["speed_loaded_kmh"]
|
||||
_V_EMPTY_KMH = _HAUL["speed_empty_kmh"]
|
||||
_T3_UNLOAD, _T4_WAIT, _T5_COVER = _HAUL["unload_min"], _HAUL["wait_min"], _HAUL["cover_min"]
|
||||
_SIXTY = Decimal(60) # 분↔시간 환산 — 값이 아니라 단위
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -66,37 +70,33 @@ _COMMON_NOTE = (
|
||||
_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
|
||||
row["work_item_code"]: DumpMaterial(
|
||||
row["work_item_code"],
|
||||
row["label"],
|
||||
Decimal(row["unit_weight_ton_per_m3"]),
|
||||
Decimal(row["loose_factor"]),
|
||||
Decimal(row["bucket_factor"]),
|
||||
Decimal(row["loader_efficiency"]),
|
||||
Decimal(row["truck_efficiency"]),
|
||||
tuple(row.get("notes") or ()),
|
||||
)
|
||||
for row in data.records("dump_material")
|
||||
}
|
||||
|
||||
|
||||
#: 「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_CYCLE_SEC = _HAUL["loading_cycle_sec"]
|
||||
_LOADING_E0 = {
|
||||
"FP-10-12-01": Decimal("0.60"),
|
||||
"FP-10-12-02": Decimal("0.35"),
|
||||
"FP-10-12-03": Decimal("0.35"),
|
||||
row["work_item_code"]: Decimal(row["loading_efficiency"])
|
||||
for row in data.records("dump_material")
|
||||
}
|
||||
#: 적재 E0 사유 — 마스터가 줄에 따로 들고 있음(운반 줄 사유와 섞지 않음).
|
||||
_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
|
||||
row["work_item_code"]: row.get("loading_note", "") for row in data.records("dump_material")
|
||||
}
|
||||
|
||||
|
||||
def loading_output(code: str) -> Decimal:
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
@@ -235,9 +234,8 @@ _CAPACITY_UNIT = re.compile(r"[((]\s*(㎥|m3|㎡|m2|m|ton|t)\s*/\s*(?:hr|시
|
||||
|
||||
def _paired_machine(node: dict[str, Any]) -> tuple[str, str, str] | None:
|
||||
"""그 표에 함께 나오는 기종 — (코드, 이름, 규격). 「유압식백호우 (…0.7㎥)」 → 0201-0070."""
|
||||
from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine
|
||||
|
||||
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
|
||||
from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine
|
||||
|
||||
catalog = load_machine_catalog()
|
||||
for table in node.get("tables", []):
|
||||
|
||||
@@ -1100,6 +1100,7 @@ def build_unit_prices(
|
||||
attach_work_items_ax(build)
|
||||
|
||||
# 기계 수송비 — 기계경비의 셋째 몫(손료·운전경비·**수송비**). 거리가 있어야 선다.
|
||||
from B09_Estimation.B09_Estimation_Transport import WORK_ITEM_CODE as TRANSPORT_CODE
|
||||
from B09_Estimation.B09_Estimation_Transport import attach_transport
|
||||
|
||||
build.transport_notes = attach_transport(
|
||||
@@ -1108,6 +1109,13 @@ def build_unit_prices(
|
||||
distance_km=transport_distance_km,
|
||||
road_key=transport_road,
|
||||
)
|
||||
# ⚠ 줄이 하나도 안 서면 **사유를 그 공종에 붙인다** — 안 그러면 수송비가 조용히 0 으로 빠진다
|
||||
# (2026-09-16 조사: 거리가 비어 모든 프로젝트에서 안 서고 있었고 사유도 안 떴다).
|
||||
# 문구는 아래 층 것 **그대로** 쓴다 — 여기서 새로 짓지 않는다(까닭은 그대로 올림).
|
||||
if build.transport_notes and not any(
|
||||
code.startswith(f"B-{TRANSPORT_CODE}") for code in build.book.titles
|
||||
):
|
||||
build.component_gaps[TRANSPORT_CODE] = build.transport_notes[0]
|
||||
# 덤프 운반 — 운반거리(B08 유토곡선·사토장)마다 한 벌(산림품셈 10-12 「2. 운반」).
|
||||
attach_dump_hauls(build, master, dump_haul_m)
|
||||
|
||||
|
||||
@@ -45,12 +45,16 @@ _RATE_NOTICE = "법이 정한 값 — 고치면 원본 갱신 때 구간 이름
|
||||
|
||||
|
||||
def _source(file_id: str) -> Path:
|
||||
"""원본 파일 자리 — 이름표가 가리키는 곳이 먼저, 아직 이름표에 없는 새 자료는 폴더에서 찾음."""
|
||||
labels = tables.load_labels()
|
||||
entry = next((f for f in labels["files"] if f["file_id"] == file_id), None)
|
||||
path = tables._file_path(entry) if entry else None
|
||||
if path is None:
|
||||
raise FileNotFoundError(f"이름표에 원본 자리가 없음: {file_id}")
|
||||
return path
|
||||
if path is not None:
|
||||
return path
|
||||
found = sorted(tables.RESOURCES.glob(f"data_*/{file_id}*.json"))
|
||||
if not found:
|
||||
raise FileNotFoundError(f"원본 자리를 못 찾음: {file_id}")
|
||||
return found[-1]
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
@@ -340,7 +344,18 @@ def _sorted(rows: list[dict[str, Any]], column: str, desc: bool) -> list[dict[st
|
||||
|
||||
#: 돌쌓기 표준경사는 B06 횡단설계가 같은 값으로 기울기를 그림 — 아홉 중 **그림에도 닿는** 하나(브레인).
|
||||
_SLOPE_NOTICE = "고치면 횡단 도면 모양도 함께 바뀜 — B06 횡단설계가 같은 값으로 벽 기울기를 그림"
|
||||
_EXTRA_NOTICE = {"rate": _RATE_NOTICE, "masonry_slope": _SLOPE_NOTICE}
|
||||
#: 같은 「1:0.3」 이 두 자리에 있음(코덱스 검증 2) — 값은 같으나 **축과 근거가 다름**.
|
||||
#: masonry_slope = 품셈 13-4-4 [주]⑪ 표준경사(직고 · 메/찰 · 성토/절토 축 · 원문표)
|
||||
#: masonry_class.face_slope = 교본 7-3 돌흙막이(구조물 **형식**별 기본값 · 판정이 안 되는 자리의 종전값)
|
||||
_CLASS_NOTICE = (
|
||||
"전면 기울기 기본값은 **교본 7-3 형식별 값** — 품셈 표준경사(직고·성토/절토 축)는 「돌쌓기 표준경사」 표에 따로 있음"
|
||||
" · 같은 1:0.3 이라도 **축과 근거가 다름**(판정이 안 되는 자리에서만 이 값으로 섬)"
|
||||
)
|
||||
_EXTRA_NOTICE = {
|
||||
"rate": _RATE_NOTICE,
|
||||
"masonry_slope": _SLOPE_NOTICE,
|
||||
"masonry_class": _CLASS_NOTICE,
|
||||
}
|
||||
|
||||
|
||||
def notice(kind: str) -> list[str]:
|
||||
@@ -353,12 +368,18 @@ def spec(kind: str, columns: list[str]) -> dict[str, Any]:
|
||||
if kind == "machine":
|
||||
return machine.spec(columns)
|
||||
if kind in base_tables.SPEC:
|
||||
editable = [c for c in base_tables.SPEC[kind]["editable"] if c in columns]
|
||||
spec_of = base_tables.SPEC[kind]
|
||||
editable = [c for c in spec_of["editable"] if c in columns]
|
||||
axis = {a for r in base_rows(kind) for a in r.get("@axis", ())}
|
||||
told = spec_of.get("locked") or {} # 계산값처럼 까닭이 따로인 칸
|
||||
return {
|
||||
"editable": editable,
|
||||
"locked": {c: _KEY if c in axis else _SOURCE for c in columns if c not in editable},
|
||||
"formula": {},
|
||||
"locked": {
|
||||
c: told.get(c) or (_KEY if c in axis else _SOURCE)
|
||||
for c in columns
|
||||
if c not in editable
|
||||
},
|
||||
"formula": {k: v for k, v in (spec_of.get("formula") or {}).items() if k in columns},
|
||||
}
|
||||
editable = {
|
||||
"labor": ["daily_wage_krw"],
|
||||
@@ -382,6 +403,37 @@ def spec(kind: str, columns: list[str]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
#: 갈래 둘 — 기초단가(원가 = 얼마인가) · 품셈 기준(수량 = 얼마나 드나). 화면은 이것으로 상자를 세움.
|
||||
BASE_PRICE_GROUP, PUMSEM_GROUP = "base_price", "pumsem_basis"
|
||||
|
||||
|
||||
def kind_group(kind: str) -> str:
|
||||
return PUMSEM_GROUP if kind in base_tables.SPEC else BASE_PRICE_GROUP
|
||||
|
||||
|
||||
def kind_label(kind: str) -> str:
|
||||
"""kind 한글 이름 — 이름표 `merged_tables` 에서만(코드에 박지 않음 · 없으면 영문 그대로)."""
|
||||
merged = tables.load_labels().get("merged_tables") or []
|
||||
named = next((t for t in merged if t.get("key") in (kind, f"{kind}s")), {})
|
||||
return named.get("name_ko") or kind
|
||||
|
||||
|
||||
def kinds() -> list[dict[str, Any]]:
|
||||
"""화면이 상자를 세울 목록 — **서버가 냄**(화면이 제 코드에 들면 새 kind 가 조용히 안 뜸).
|
||||
|
||||
갈래 · kind · 한글 이름(이름표) · 줄 수.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"group": kind_group(kind),
|
||||
"kind": kind,
|
||||
"label": kind_label(kind),
|
||||
"rows": len(rows(kind)),
|
||||
}
|
||||
for kind in KINDS
|
||||
]
|
||||
|
||||
|
||||
def row_label(kind: str, row: dict[str, Any] | None) -> str:
|
||||
if row is None:
|
||||
return ""
|
||||
|
||||
@@ -281,6 +281,73 @@ def _masonry_class(doc: dict[str, Any], name: str) -> list[_ROW]:
|
||||
return rows
|
||||
|
||||
|
||||
def _machine_productivity(doc: dict[str, Any], name: str) -> list[_ROW]:
|
||||
"""기계 작업량 밑값 — 코드에 박혀 있던 값을 꺼낸 자리(불도저 속도·삽날 · 덤프 운반·적재 계수).
|
||||
|
||||
⚠ 식(`Q = n·q·f·E` 따위)은 값이 아니라 로직이라 계산 모듈에 남는다 — 여기는 계수만.
|
||||
"""
|
||||
rows = []
|
||||
for r in doc["variables"]["dozer_speed"]["records"]:
|
||||
rows.append(
|
||||
_row(
|
||||
f"dozer_speed/{r['track']}/{r['tonnage_ton']}/{r['gear']}",
|
||||
name,
|
||||
("table", "track", "tonnage_ton", "gear"),
|
||||
table="dozer_speed",
|
||||
track=r["track"],
|
||||
tonnage_ton=float(r["tonnage_ton"]),
|
||||
gear=int(r["gear"]),
|
||||
forward_m_per_min=float(r["forward_m_per_min"]),
|
||||
reverse_m_per_min=float(r["reverse_m_per_min"]),
|
||||
)
|
||||
)
|
||||
for r in doc["variables"]["dozer_blade"]["records"]:
|
||||
rows.append(
|
||||
_row(
|
||||
f"dozer_blade/{r['track']}/{r['tonnage_ton']}",
|
||||
name,
|
||||
("table", "track", "tonnage_ton"),
|
||||
table="dozer_blade",
|
||||
track=r["track"],
|
||||
tonnage_ton=float(r["tonnage_ton"]),
|
||||
blade_m3=float(r["blade_m3"]),
|
||||
)
|
||||
)
|
||||
for r in doc["variables"]["dump_haul"]["records"]:
|
||||
rows.append(
|
||||
_row(
|
||||
f"dump_haul/{r['key']}",
|
||||
name,
|
||||
("table", "key"),
|
||||
table="dump_haul",
|
||||
key=r["key"],
|
||||
value=float(r["value"]),
|
||||
unit=r.get("unit"),
|
||||
meaning=r.get("meaning"),
|
||||
)
|
||||
)
|
||||
for r in doc["variables"]["dump_material"]["records"]:
|
||||
rows.append(
|
||||
_row(
|
||||
f"dump_material/{r['work_item_code']}",
|
||||
name,
|
||||
("table", "work_item_code"),
|
||||
table="dump_material",
|
||||
work_item_code=r["work_item_code"],
|
||||
label=r["label"],
|
||||
unit_weight_ton_per_m3=float(r["unit_weight_ton_per_m3"]),
|
||||
loose_factor=float(r["loose_factor"]),
|
||||
bucket_factor=float(r["bucket_factor"]),
|
||||
loader_efficiency=float(r["loader_efficiency"]),
|
||||
truck_efficiency=float(r["truck_efficiency"]),
|
||||
loading_efficiency=float(r["loading_efficiency"]),
|
||||
notes=r.get("notes"),
|
||||
loading_note=r.get("loading_note"),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
#: kind → 원본 파일 id · 줄 만드는 길 · 고칠 칸. 이름은 **자료 이름 그대로**(브레인).
|
||||
SPEC: dict[str, dict[str, Any]] = {
|
||||
"coef": {"file": "coef", "build": _coef, "editable": ["min", "max"]},
|
||||
@@ -295,9 +362,17 @@ SPEC: dict[str, dict[str, Any]] = {
|
||||
"editable": ["reuse_count", "ratio_pct", "daily_area_m2"],
|
||||
},
|
||||
"rebar_complexity": {
|
||||
# ⚠ 단가 참고값은 **B09 가 자재 단가로 셈한 값**이라 고칠 칸이 아님(코덱스 검증 1 · 2026-09-16).
|
||||
# 사용자 잣대 「마스터 = 계산으로 나오는 값이 아니라 값 그 자체」와 어긋나 editable 에서 뺌.
|
||||
"file": "rebar_complexity",
|
||||
"build": _rebar_complexity,
|
||||
"editable": ["price_krw_per_ton"],
|
||||
"editable": [],
|
||||
"locked": {
|
||||
"price_krw_per_ton": "계산값 — B09 가 자재 단가로 셈(표시 전용 · 밑값을 고쳐야 바뀜)"
|
||||
},
|
||||
"formula": {
|
||||
"price_krw_per_ton": "갈래별 이형철근 단가 참고값 = B09 가 자재 단가에서 셈 — 표시 전용(B08 계산에 안 듦)"
|
||||
},
|
||||
},
|
||||
"timber_structure_class": {
|
||||
"file": "timber_structure_class",
|
||||
@@ -315,6 +390,22 @@ SPEC: dict[str, dict[str, Any]] = {
|
||||
"editable": ["min_cm", "max_cm"],
|
||||
},
|
||||
"stone_kind": {"file": "stone_kind", "build": _stone_kind, "editable": ["m3_per_m2", "ratio"]},
|
||||
"machine_productivity": {
|
||||
"file": "machine_productivity",
|
||||
"build": _machine_productivity,
|
||||
"editable": [
|
||||
"forward_m_per_min",
|
||||
"reverse_m_per_min",
|
||||
"blade_m3",
|
||||
"value",
|
||||
"unit_weight_ton_per_m3",
|
||||
"loose_factor",
|
||||
"bucket_factor",
|
||||
"loader_efficiency",
|
||||
"truck_efficiency",
|
||||
"loading_efficiency",
|
||||
],
|
||||
},
|
||||
"masonry_class": {
|
||||
"file": "masonry_class",
|
||||
"build": _masonry_class,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
GET /api/master-data/tree 갈래 → 파일 → 표
|
||||
GET /api/master-data/rows?file=&table=&page=&size=&q= 표 줄(쪽 나누기 · 검색)
|
||||
GET /api/master-data/base-prices/{kind}?page=&size=&q=&sort=&desc= 기초단가 한 표(labor|machine|material|oil|rate)
|
||||
GET /api/master-data/base-prices 표 목록(갈래·kind·이름·줄 수)
|
||||
GET /api/master-data/base-prices/{kind}?page=&size=&q=&sort=&desc= 한 표(기초단가 다섯 · 품셈 기준 열)
|
||||
PUT /api/master-data/base-prices/{kind}/{row_id} {values:{열:값}} → 덮개에만 씀 · null = 되돌리기
|
||||
GET /api/master-data/overrides 고친 것·원본 바뀐 것·주인 없는 것(서버 정렬)
|
||||
⚠ 권한은 등록하는 쪽(`main.py` · 랩탑 서브)이 `dependencies=[verify_session, require_system_admin]` 로 붙임.
|
||||
@@ -48,6 +49,12 @@ def _kind(kind: str) -> str:
|
||||
return kind
|
||||
|
||||
|
||||
@router.get("/base-prices")
|
||||
def get_base_price_kinds() -> dict:
|
||||
"""어떤 표가 있나 — 갈래·kind·한글 이름·줄 수. 화면이 목록을 제 코드에 들지 않게 서버가 냄."""
|
||||
return {"kinds": base_prices.kinds()}
|
||||
|
||||
|
||||
@router.get("/base-prices/{kind}")
|
||||
def get_base_prices(
|
||||
kind: str,
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "machine_productivity",
|
||||
"effective_date": "2026-01-01",
|
||||
"note": "기계 작업량 밑값 — 코드에 박혀 있던 값을 그대로 옮긴 벌(2026-09-16 사용자 원칙 「마스터 = 관리자가 제어할 값 · 코드 안에 있으면 안 됨」). 값은 한 칸도 안 바꿨다 — 옮기기 전후 금액이 같다.",
|
||||
"policy": {
|
||||
"no_value_changed": "코드에 있던 값을 그대로 옮겼다. 원문과의 대조는 시험이 지킨다.",
|
||||
"formula_stays_in_code": "식(Q = n·q·f·E 따위)은 값이 아니라 로직이라 코드에 둔다 — 계수만 여기 있다.",
|
||||
"source_tables_mashed": "삽날 용량표는 원문 옮기기에서 절 이름이 뭉개져(`5-3.2-5.5-타 이 어…`) 표로 안 들어왔다. 값은 원문 본문 것이고, 원문 표가 제 모양으로 들어오면 그때 대조한다."
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"doc": "건설공사 표준품셈 8-2-1 불도저 2.가·나 전진·후진 속도",
|
||||
"tables": [
|
||||
"C0426",
|
||||
"C0427"
|
||||
],
|
||||
"role": "primary"
|
||||
},
|
||||
{
|
||||
"doc": "건설공사 표준품셈 8-2-1 1.가 삽날 용량 q˚",
|
||||
"tables": [],
|
||||
"role": "primary",
|
||||
"note": "원문 표가 뭉개져 카탈로그에 표로 없음"
|
||||
},
|
||||
{
|
||||
"doc": "산림사업 표준품셈(고시 제2025-82호) 10-12 「1. 적재」·「2. 운반」 식 서식",
|
||||
"tables": [],
|
||||
"role": "primary",
|
||||
"note": "원문이 표가 아니라 채워 넣는 식이라 공종 마스터에 안 실림"
|
||||
}
|
||||
],
|
||||
"variables": {
|
||||
"dozer_speed": {
|
||||
"unit": "m/분",
|
||||
"key": "track+tonnage_ton+gear",
|
||||
"note": "후진이 「-」인 단은 원문에 값이 없어 담지 않는다(없는 속도로 싸이클이 서면 값이 조용히 틀림).",
|
||||
"records": [
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "4",
|
||||
"gear": 1,
|
||||
"forward_m_per_min": "40",
|
||||
"reverse_m_per_min": "63"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "4",
|
||||
"gear": 2,
|
||||
"forward_m_per_min": "57",
|
||||
"reverse_m_per_min": "85"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "7",
|
||||
"gear": 1,
|
||||
"forward_m_per_min": "43",
|
||||
"reverse_m_per_min": "53"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "7",
|
||||
"gear": 2,
|
||||
"forward_m_per_min": "67",
|
||||
"reverse_m_per_min": "78"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "7",
|
||||
"gear": 3,
|
||||
"forward_m_per_min": "92",
|
||||
"reverse_m_per_min": "107"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "10",
|
||||
"gear": 1,
|
||||
"forward_m_per_min": "42",
|
||||
"reverse_m_per_min": "50"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "10",
|
||||
"gear": 2,
|
||||
"forward_m_per_min": "64",
|
||||
"reverse_m_per_min": "75"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "10",
|
||||
"gear": 3,
|
||||
"forward_m_per_min": "88",
|
||||
"reverse_m_per_min": "105"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "12",
|
||||
"gear": 1,
|
||||
"forward_m_per_min": "40",
|
||||
"reverse_m_per_min": "48"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "12",
|
||||
"gear": 2,
|
||||
"forward_m_per_min": "55",
|
||||
"reverse_m_per_min": "70"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "12",
|
||||
"gear": 3,
|
||||
"forward_m_per_min": "75",
|
||||
"reverse_m_per_min": "100"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "13",
|
||||
"gear": 1,
|
||||
"forward_m_per_min": "40",
|
||||
"reverse_m_per_min": "48"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "13",
|
||||
"gear": 2,
|
||||
"forward_m_per_min": "55",
|
||||
"reverse_m_per_min": "70"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "19",
|
||||
"gear": 1,
|
||||
"forward_m_per_min": "40",
|
||||
"reverse_m_per_min": "46"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "19",
|
||||
"gear": 2,
|
||||
"forward_m_per_min": "55",
|
||||
"reverse_m_per_min": "70"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "19",
|
||||
"gear": 3,
|
||||
"forward_m_per_min": "75",
|
||||
"reverse_m_per_min": "98"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "32",
|
||||
"gear": 1,
|
||||
"forward_m_per_min": "40",
|
||||
"reverse_m_per_min": "43"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "32",
|
||||
"gear": 2,
|
||||
"forward_m_per_min": "52",
|
||||
"reverse_m_per_min": "58"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "32",
|
||||
"gear": 3,
|
||||
"forward_m_per_min": "70",
|
||||
"reverse_m_per_min": "78"
|
||||
},
|
||||
{
|
||||
"track": "타이어",
|
||||
"tonnage_ton": "15",
|
||||
"gear": 1,
|
||||
"forward_m_per_min": "83",
|
||||
"reverse_m_per_min": "92"
|
||||
},
|
||||
{
|
||||
"track": "타이어",
|
||||
"tonnage_ton": "15",
|
||||
"gear": 2,
|
||||
"forward_m_per_min": "200",
|
||||
"reverse_m_per_min": "125"
|
||||
},
|
||||
{
|
||||
"track": "타이어",
|
||||
"tonnage_ton": "28",
|
||||
"gear": 1,
|
||||
"forward_m_per_min": "92",
|
||||
"reverse_m_per_min": "92"
|
||||
},
|
||||
{
|
||||
"track": "타이어",
|
||||
"tonnage_ton": "28",
|
||||
"gear": 2,
|
||||
"forward_m_per_min": "200",
|
||||
"reverse_m_per_min": "200"
|
||||
},
|
||||
{
|
||||
"track": "타이어",
|
||||
"tonnage_ton": "33",
|
||||
"gear": 1,
|
||||
"forward_m_per_min": "92",
|
||||
"reverse_m_per_min": "110"
|
||||
},
|
||||
{
|
||||
"track": "타이어",
|
||||
"tonnage_ton": "33",
|
||||
"gear": 2,
|
||||
"forward_m_per_min": "210",
|
||||
"reverse_m_per_min": "250"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dozer_blade": {
|
||||
"unit": "㎥",
|
||||
"key": "track+tonnage_ton",
|
||||
"note": "무한궤도 10·13 톤이 둘 다 1.5 ㎥ 라 용량만으로는 못 가른다 — 속도로 마저 가른다.",
|
||||
"records": [
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "4",
|
||||
"blade_m3": "0.5"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "7",
|
||||
"blade_m3": "1.1"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "10",
|
||||
"blade_m3": "1.5"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "12",
|
||||
"blade_m3": "2.0"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "13",
|
||||
"blade_m3": "1.5"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "19",
|
||||
"blade_m3": "3.2"
|
||||
},
|
||||
{
|
||||
"track": "무한궤도",
|
||||
"tonnage_ton": "32",
|
||||
"blade_m3": "5.5"
|
||||
},
|
||||
{
|
||||
"track": "타이어",
|
||||
"tonnage_ton": "15",
|
||||
"blade_m3": "3.1"
|
||||
},
|
||||
{
|
||||
"track": "타이어",
|
||||
"tonnage_ton": "28",
|
||||
"blade_m3": "4.0"
|
||||
},
|
||||
{
|
||||
"track": "타이어",
|
||||
"tonnage_ton": "33",
|
||||
"blade_m3": "5.7"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dump_haul": {
|
||||
"key": "key",
|
||||
"note": "덤프 운반·적재 식이 쓰는 밑값. 장비 규격이 바뀌면 여기를 고친다.",
|
||||
"records": [
|
||||
{
|
||||
"key": "truck_ton",
|
||||
"value": "15",
|
||||
"unit": "ton",
|
||||
"meaning": "덤프트럭 적재 규격 T"
|
||||
},
|
||||
{
|
||||
"key": "bucket_m3",
|
||||
"value": "0.7",
|
||||
"unit": "㎥",
|
||||
"meaning": "적재기계 버켓 용량 q"
|
||||
},
|
||||
{
|
||||
"key": "loader_cycle_sec",
|
||||
"value": "20",
|
||||
"unit": "초",
|
||||
"meaning": "적재 대기 싸이클 ㎝s"
|
||||
},
|
||||
{
|
||||
"key": "speed_loaded_kmh",
|
||||
"value": "5",
|
||||
"unit": "㎞/hr",
|
||||
"meaning": "적재 주행속도 V1"
|
||||
},
|
||||
{
|
||||
"key": "speed_empty_kmh",
|
||||
"value": "6",
|
||||
"unit": "㎞/hr",
|
||||
"meaning": "공차 주행속도 V2"
|
||||
},
|
||||
{
|
||||
"key": "unload_min",
|
||||
"value": "1.1",
|
||||
"unit": "분",
|
||||
"meaning": "적하 시간 t3"
|
||||
},
|
||||
{
|
||||
"key": "wait_min",
|
||||
"value": "0.9",
|
||||
"unit": "분",
|
||||
"meaning": "대기 시간 t4"
|
||||
},
|
||||
{
|
||||
"key": "cover_min",
|
||||
"value": "0.5",
|
||||
"unit": "분",
|
||||
"meaning": "덮개 시간 t5"
|
||||
},
|
||||
{
|
||||
"key": "loading_cycle_sec",
|
||||
"value": "22",
|
||||
"unit": "초",
|
||||
"meaning": "적재 식 싸이클 ㎝(180°)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dump_material": {
|
||||
"key": "work_item_code",
|
||||
"note": "10-12 절 재료별 값 — 고른 값·버린 값 사유를 줄에 함께 둔다. 적재 사유는 `loading_note` 로 따로 둔다(운반 줄 사유와 섞지 않음).",
|
||||
"records": [
|
||||
{
|
||||
"work_item_code": "FP-10-12-01",
|
||||
"label": "토사",
|
||||
"unit_weight_ton_per_m3": "1.9",
|
||||
"loose_factor": "1.3",
|
||||
"bucket_factor": "0.9",
|
||||
"loader_efficiency": "0.85",
|
||||
"truck_efficiency": "0.9",
|
||||
"loading_efficiency": "0.60",
|
||||
"notes": [
|
||||
"n 의 Qt = 계산값 T/γt×L(원문 표기 「10/(0.7×K)」의 10 은 토사 어림수 복사로 봄 — 버림)",
|
||||
"Es = 운반 줄 값(적재 식의 E0 와 다른 기호)"
|
||||
],
|
||||
"loading_note": "E0 = 0.60(표 「임도」·10-12-3 [주]③) — 본문 표기 0.75 버림"
|
||||
},
|
||||
{
|
||||
"work_item_code": "FP-10-12-02",
|
||||
"label": "암절취",
|
||||
"unit_weight_ton_per_m3": "2.4",
|
||||
"loose_factor": "1.35",
|
||||
"bucket_factor": "0.55",
|
||||
"loader_efficiency": "0.35",
|
||||
"truck_efficiency": "0.9",
|
||||
"loading_efficiency": "0.35",
|
||||
"notes": [
|
||||
"n 의 Qt = 계산값 T/γt×L(원문 표기 「10/(0.7×K)」의 10 은 토사 어림수 복사로 봄 — 버림) · 원문 10 이면 n 1.18배",
|
||||
"Es = 운반 줄 값(적재 식의 E0 와 다른 기호)"
|
||||
],
|
||||
"loading_note": "E0 = 0.35(본문·표 「파쇄암」 같음)"
|
||||
},
|
||||
{
|
||||
"work_item_code": "FP-10-12-03",
|
||||
"label": "발파암",
|
||||
"unit_weight_ton_per_m3": "2.4",
|
||||
"loose_factor": "1.625",
|
||||
"bucket_factor": "0.55",
|
||||
"loader_efficiency": "0.35",
|
||||
"truck_efficiency": "0.9",
|
||||
"loading_efficiency": "0.35",
|
||||
"notes": [
|
||||
"n 의 Qt = 계산값 T/γt×L(원문 표기 「10/(0.7×K)」의 10 은 토사 어림수 복사로 봄 — 버림)",
|
||||
"Es = 운반 줄 값(적재 식의 E0 와 다른 기호)",
|
||||
"E 원문 누락 — 0.9 적용(같은 절 토사·암절취 · 덤프 작업효율)"
|
||||
],
|
||||
"loading_note": "E0 = 0.35(10-12-3 [주]③ 「파쇄암 0.35」)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"""B09 기계 수송비 — 거리가 비면 **사유가 붙은 빈 줄**이 선다(2026-09-16 브레인 · 사용자 지시).
|
||||
|
||||
기계경비는 손료 + 운전경비 + **수송비**(건설품셈 8-1-6의 1)인데, 수송비는 거리(인근 시·군·구청
|
||||
소재지 → 현장)가 설계 입력이라 안 넣으면 줄이 안 섰다. ⚠ 그때 **사유도 안 떠서** 모든 프로젝트에서
|
||||
수송비가 조용히 0 이었다(2026-09-16 조사) — 「조용히 빠짐」은 우리가 내내 막아 온 자리다.
|
||||
⇒ 금액은 지어내지 않는다. **사유만** 세운다 — 문구는 아래 층(`B09_Estimation_Transport`) 것 그대로.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from B09_Estimation.B09_Estimation_Transport import WORK_ITEM_CODE
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
|
||||
|
||||
|
||||
def test_거리가_비면_사유가_서고_금액은_안_섬() -> None:
|
||||
build = cached_build()
|
||||
assert f"B-{WORK_ITEM_CODE}" not in build.book.titles # 금액을 지어내지 않음
|
||||
gap = build.component_gaps.get(WORK_ITEM_CODE) or ""
|
||||
assert "거리" in gap and "산출 조건" in gap
|
||||
assert gap in build.transport_notes # 아래 층 문구 그대로 — 새로 짓지 않음
|
||||
|
||||
|
||||
def test_거리를_넣으면_줄이_서고_사유는_사라짐() -> None:
|
||||
build = cached_build(transport_distance_km="12", transport_road="paved")
|
||||
codes = [c for c in build.book.titles if c.startswith(f"B-{WORK_ITEM_CODE}#")]
|
||||
assert codes, "거리를 넣었는데 수송비 줄이 안 섬"
|
||||
assert WORK_ITEM_CODE not in build.component_gaps
|
||||
detail = build.book.details[codes[0]][0]
|
||||
assert detail.quantity > Decimal(0)
|
||||
|
||||
|
||||
def test_한_갈래만_서면_사유는_그_갈래에만_남음() -> None:
|
||||
"""고속4차선은 원문에 트레일러 속도가 「-」 — 덤프 줄은 서고 트레일러만 사유."""
|
||||
build = cached_build(transport_distance_km="12", transport_road="expressway_4")
|
||||
assert [c for c in build.book.titles if c.startswith(f"B-{WORK_ITEM_CODE}#")]
|
||||
assert build.transport_notes and any("원문에 없습니다" in n for n in build.transport_notes)
|
||||
assert WORK_ITEM_CODE not in build.component_gaps # 줄이 섰으면 공종 사유는 안 붙음
|
||||
@@ -14,6 +14,8 @@ from __future__ import annotations
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -31,6 +33,7 @@ NEW_KINDS = (
|
||||
"masonry_slope",
|
||||
"masonry_back_length",
|
||||
"stone_kind",
|
||||
"machine_productivity", # 2026-09-16 코드에서 꺼낸 기계 작업량 밑값
|
||||
"masonry_class",
|
||||
)
|
||||
|
||||
@@ -63,9 +66,8 @@ def test_아홉이_기초단가와_같은_길로_섬(client: TestClient) -> None
|
||||
assert table["total"] == len(table["rows"]) > 0, kind
|
||||
ids = _ids(table)
|
||||
assert len(ids) == len(set(ids)), kind # ⭐ 열쇠가 겹치면 덮개가 엉뚱한 줄에 붙음
|
||||
assert table["editable"] and set(table["editable"]) <= {
|
||||
c["key"] for c in table["columns"]
|
||||
}, kind
|
||||
assert set(table["editable"]) <= {c["key"] for c in table["columns"]}, kind
|
||||
assert table["editable"] or kind == "rebar_complexity", kind # 철근은 값이 다 계산·글
|
||||
assert set(table["locked"]) | set(table["editable"]) == set(table["sortable"]), kind
|
||||
axis = {a for r in base_prices.base_rows(kind) for a in r.get("@axis", ())}
|
||||
assert {table["locked"][c] for c in axis if c in table["locked"]} == {
|
||||
@@ -185,3 +187,118 @@ def test_고치기도_같은_길(client: TestClient, tmp_path: Path) -> None:
|
||||
json={"values": {"reuse_count": 3}},
|
||||
)
|
||||
assert value_edit.status_code == 200 and value_edit.json()["reuse_count"] == 3
|
||||
|
||||
|
||||
def test_코드에_박혔던_기계_밑값이_마스터로_나옴(client: TestClient) -> None:
|
||||
"""사용자 원칙 — 마스터 = 관리자가 제어할 값 · **코드 안에 있으면 안 됨**(2026-09-16).
|
||||
|
||||
불도저 속도·삽날(건설품셈 8-2-1)과 덤프 운반·적재 계수(산림품셈 10-12 식 서식)를 꺼낸 자리.
|
||||
"""
|
||||
table = _get(client, "machine_productivity", size=500)
|
||||
rows = {r["@id"]: r for r in table["rows"]}
|
||||
assert table["total"] == 25 + 10 + 9 + 3
|
||||
assert rows["dozer_speed/무한궤도/7/2"]["forward_m_per_min"] == 67
|
||||
assert rows["dozer_speed/타이어/33/2"]["reverse_m_per_min"] == 250
|
||||
assert rows["dozer_blade/무한궤도/19"]["blade_m3"] == 3.2
|
||||
assert rows["dump_haul/truck_ton"]["value"] == 15
|
||||
assert rows["dump_material/FP-10-12-01"]["loose_factor"] == 1.3
|
||||
assert "forward_m_per_min" in table["editable"] and "track" in table["locked"]
|
||||
|
||||
|
||||
def test_꺼낸_값은_코드가_쓰던_것과_원문과_같음() -> None:
|
||||
"""옮기며 값이 바뀌면 금액이 조용히 달라짐 — 코드가 읽는 값과 품셈 원문 둘 다에 댐."""
|
||||
import json
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import _DOZER_SPEEDS
|
||||
from B09_Estimation.B09_Estimation_MachineProductivity_Dump import DUMP_MATERIALS
|
||||
|
||||
assert _DOZER_SPEEDS["무한궤도"][Decimal("12")][2] == (Decimal(55), Decimal(70))
|
||||
assert DUMP_MATERIALS["FP-10-12-03"].truck_efficiency == Decimal("0.9")
|
||||
from B09_Estimation import B09_Estimation_MachineProductivity_Dump as dump
|
||||
|
||||
assert (dump._TRUCK_TON, dump._BUCKET_M3, dump._LOADER_CYCLE_SEC) == (
|
||||
Decimal(15),
|
||||
Decimal("0.7"),
|
||||
Decimal(20),
|
||||
)
|
||||
assert (dump._V_LOADED_KMH, dump._V_EMPTY_KMH, dump._LOADING_CYCLE_SEC) == (
|
||||
Decimal(5),
|
||||
Decimal(6),
|
||||
Decimal(22),
|
||||
)
|
||||
pum = json.loads(
|
||||
(ROOT / "resources/data_cost_input_value/pum_const_2026.json").read_text(encoding="utf-8")
|
||||
)["variables"]["pum"]["tables"]
|
||||
by_id = {t["table_id"]: t for t in pum}
|
||||
|
||||
def source(table_id: str, gears: int) -> dict:
|
||||
out = {}
|
||||
for row in by_id[table_id]["rows"][1:]:
|
||||
ton = re.sub(r"\(.*?\)", "", str(row[0])).strip()
|
||||
if not ton.replace(".", "").isdigit():
|
||||
continue
|
||||
cells = [str(v).strip() for v in row[1:]]
|
||||
out[Decimal(ton)] = {
|
||||
i + 1: (f, r)
|
||||
for i, (f, r) in enumerate(zip(cells[:gears], cells[gears : gears * 2]))
|
||||
}
|
||||
return out
|
||||
|
||||
for track, table_id, gears in (("무한궤도", "C0426", 4), ("타이어", "C0427", 3)):
|
||||
original = source(table_id, gears)
|
||||
for ton, speeds in _DOZER_SPEEDS[track].items():
|
||||
for gear, (forward, reverse) in speeds.items():
|
||||
assert (str(forward), str(reverse)) == original[ton][gear], (track, ton, gear)
|
||||
|
||||
|
||||
def test_계산값은_고칠_칸이_아니고_까닭이_보임(client: TestClient) -> None:
|
||||
"""코덱스 검증 1 — 철근 단가 참고값은 **B09 계산값**인데 고칠 수 있는 마스터 값으로 앉아 있었음."""
|
||||
rebar = _get(client, "rebar_complexity", size=10)
|
||||
assert "price_krw_per_ton" not in rebar["editable"]
|
||||
assert "계산값" in rebar["locked"]["price_krw_per_ton"]
|
||||
assert "표시 전용" in rebar["formula"]["price_krw_per_ton"]
|
||||
machine = _get(client, "machine", size=1) # 기계 계산 넷도 같은 꼴(잠김 + 식)
|
||||
for column in ("hourly_loss_krw", "hourly_fuel_krw", "hourly_operator_krw", "hourly_total_krw"):
|
||||
assert "계산값" in machine["locked"][column] and column in machine["formula"]
|
||||
|
||||
|
||||
def test_같은_기울기가_두_자리인_까닭을_표가_말함(client: TestClient) -> None:
|
||||
"""코덱스 검증 2 — 값은 같은 1:0.3 이나 **축과 근거가 다름**(품셈 직고·성절토 ↔ 교본 형식별)."""
|
||||
notice = _get(client, "masonry_class", size=1)["notice"]
|
||||
assert any("교본 7-3" in line and "돌쌓기 표준경사" in line for line in notice)
|
||||
assert any("축과 근거가 다름" in line for line in notice)
|
||||
slope = _get(client, "masonry_slope", size=1)["notice"]
|
||||
assert any("횡단 도면" in line for line in slope) # 표준경사 쪽 알림은 그대로
|
||||
|
||||
|
||||
def test_kind_목록을_서버가_줌_새_kind_도_저절로(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""화면이 kind 목록을 제 코드에 들면 **새 kind 가 조용히 안 뜸**(기계 작업량이 그랬음 · 브레인).
|
||||
|
||||
⇒ 갈래·kind·한글 이름·줄 수를 서버가 냄. 화면은 상자만 세운다.
|
||||
"""
|
||||
listed = client.get("/api/master-data/base-prices")
|
||||
assert listed.status_code == 200, listed.text
|
||||
items = listed.json()["kinds"]
|
||||
assert [i["kind"] for i in items] == list(base_prices.KINDS)
|
||||
groups = {i["kind"]: i["group"] for i in items}
|
||||
assert groups["labor"] == "base_price" and groups["machine_productivity"] == "pumsem_basis"
|
||||
assert {i["group"] for i in items} == {"base_price", "pumsem_basis"}
|
||||
labor = next(i for i in items if i["kind"] == "labor")
|
||||
assert labor["label"] == "노임" and labor["rows"] == 261 # 이름은 이름표에서
|
||||
assert next(i for i in items if i["kind"] == "machine_productivity")["rows"] == 47
|
||||
|
||||
from Z01_MasterData import Z01_MasterData_BasePrices_Tables as base_tables
|
||||
|
||||
fake = dict(base_tables.SPEC)
|
||||
fake["coef_fake"] = dict(base_tables.SPEC["coef"])
|
||||
monkeypatch.setattr(base_tables, "SPEC", fake)
|
||||
monkeypatch.setattr(base_prices, "KINDS", (*base_prices.KINDS, "coef_fake"))
|
||||
grown = client.get("/api/master-data/base-prices").json()["kinds"]
|
||||
assert [i["kind"] for i in grown][-1] == "coef_fake" # 더하면 목록이 저절로 늚
|
||||
assert (
|
||||
next(i for i in grown if i["kind"] == "coef_fake")["label"] == "coef_fake"
|
||||
) # 이름표 전엔 영문
|
||||
|
||||
Reference in New Issue
Block a user