5 Commits
Author SHA1 Message Date
eomsangdonandClaude Opus 5 28a5bcb636 feat(z01): 표 목록을 서버가 냄 — GET /api/master-data/base-prices 가 갈래(base_price·pumsem_basis) · kind · 한글 이름(이름표에서) · 줄 수를 줌 · 화면이 kind 목록을 제 코드에 들면 새 kind 가 조용히 안 뜸(기계 작업량이 그랬음) · kind 를 하나 더하면 목록이 저절로 느는지 시험으로 못박음(브레인)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-16 15:56:24 +09:00
eomsangdonandClaude Opus 5 389d014018 fix(z01): 코덱스 검증 둘 — 철근 단가 참고값은 B09 계산값이라 고칠 칸에서 빼고 「계산값 · 표시 전용」 까닭·식을 실음(사용자 잣대 「마스터 = 계산으로 나오는 값이 아님」) · 돌쌓기 갈래 표에 같은 1:0.3 이 두 자리인 까닭 알림(품셈 13-4-4 [주]⑪ 직고·성절토 축 ↔ 교본 7-3 형식별 기본값 — 축과 근거가 다름)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-16 15:48:20 +09:00
eomsangdonandClaude Opus 5 3c4b915f7e fix(b09): 기계 수송비가 조용히 0 으로 빠지던 자리 — 거리(인근 시·군·구청 → 현장)가 비어 중기운반(10-4) 줄이 안 설 때 사유를 그 공종에 붙임(아래 층 문구 그대로 · 금액은 안 지어냄) · 줄이 서면 사유 안 붙음 · 조사에서 모든 프로젝트가 수송비 0 이고 사유도 없던 것을 확인(브레인)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-16 15:41:58 +09:00
eomsangdon b6fb372543 auto: 2026-09-16 15:19 (ESD_LAPTOP) 2026-09-16 15:19:09 +09:00
eomsangdonandClaude Opus 5 028a6be247 feat(z01·b09): 코드에 박혀 있던 기계 작업량 밑값을 마스터로 꺼냄 — 새 자료 data_machine_productivity(불도저 속도 25 · 삽날 10 · 덤프 운반·적재 밑값 9 · 재료별 3) · B09 는 그 파일을 읽음(값은 한 칸도 안 바꿈 · 원문 pum_const C0426·C0427 대조 시험) · Z01 kind machine_productivity 로 관리자가 고칠 수 있게 · 식은 값이 아니라 로직이라 코드에 남김 · 이름표에 없는 새 자료는 폴더에서 찾음(사용자 원칙 「마스터 = 관리자가 제어할 값 · 코드 안에 있으면 안 됨」 · 브레인)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-16 15:19:08 +09:00
11 changed files with 814 additions and 92 deletions
@@ -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)
+59 -7
View File
@@ -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,
+8 -1
View File
@@ -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 # 줄이 섰으면 공종 사유는 안 붙음
+120 -3
View File
@@ -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"
) # 이름표 전엔 영문