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

This commit is contained in:
2026-09-08 04:43:41 +09:00
9 changed files with 319 additions and 79 deletions
@@ -33,6 +33,13 @@ export interface StructureOptionField {
phase?: "b05" | "detail";
}
/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세
* 치수까지** 받는다(2026-08-17 사용자 확정). 부르는 쪽이 정한다. */
export interface StructuresSectionOptions {
/** 참이면 `phase: "detail"` 옵션(뒷길이·돌규격·형식 …)도 폼에 그린다. */
includeDetail?: boolean;
}
/** B05 배치 폼에 그릴 옵션인가 — 상세(detail)는 B06/B07 몫이라 숨긴다. */
export function isB05Option(option: StructureOptionField): boolean {
return (option.phase ?? "b05") !== "detail";
@@ -956,6 +956,15 @@
"required": true,
"phase": "detail"
},
{
"key": "bond",
"label": "쌓기 방식",
"input": "select",
"choices": ["메쌓기", "찰쌓기"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "side",
"label": "설치 측",
+14 -5
View File
@@ -18,6 +18,7 @@ import {
isB05Option,
structureAnchorM,
type StructureInstance,
type StructuresSectionOptions,
type StructurePlacement,
type StructureType,
} from "./B05_Profile_Api_Structures";
@@ -49,7 +50,10 @@ import type {
StructuresSection,
} from "./B05_Profile_UI_Structures_Panel_Types";
export function createStructuresSection(callbacks: StructuresCallbacks): StructuresSection {
export function createStructuresSection(
callbacks: StructuresCallbacks,
sectionOptions: StructuresSectionOptions = {},
): StructuresSection {
// 폼 뼈대는 전용 조립기가 세운다(2026-09-03 · 700줄 제한) — 여기서는 값·검증·저장만.
const form = buildStructuresForm({
getInterval: () => callbacks.getInterval(),
@@ -294,8 +298,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
syncRangeDisplay();
}
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 상세(detail) 옵션은 B06/B07
* 몫이라 그리지 않는다(B05 = 유무·종류·위치 단계, 2026-08-17 사용자 확정). */
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다. 상세(detail)는 `includeDetail` 인 화면
* (B06/B07)에서만 — 안 받으면 뒷길이·돌규격·형식이 비어 **수량이 갈래를 못 고른다**. */
function renderOptionFields(values: Record<string, string | number> = {}): void {
const type = currentType();
// 여기도 배열을 갈아끼우지 않는다 — 조각들이 참조로 받아 두므로 새 배열로
@@ -303,7 +307,9 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
optionInputs.length = 0;
optionRow.replaceChildren();
// 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다.
const visible = type && !facilityFormKind(type) ? type.options.filter(isB05Option) : [];
const all = sectionOptions.includeDetail === true;
const visible =
type && !facilityFormKind(type) ? type.options.filter((o) => all || isB05Option(o)) : [];
if (!visible.length) {
optionRow.hidden = true;
syncOptionLock();
@@ -317,8 +323,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
// 빈("선택하세요") 항목은 두지 않는다 — 첫 항목이 곧 기본값이고, 기본값은
// 구조물별로 레지스트리에서 지정한다(2026-08-17 사용자 지시 1).
const choices = option.choices.map((choice) => [choice, choice] as [string, string]);
// 기본값 없는 필수 항목은 **빈 칸으로** — 첫 항목을 슬쩍 고르면 근거 없는 값이 나간다.
const mustPick = option.required === true && (option.default ?? "") === "";
if (mustPick) choices.unshift(["", "— 선택 —"]);
input = select(choices);
input.value = String(preset || (option.choices[0] ?? ""));
input.value = String(preset || (mustPick ? "" : (option.choices[0] ?? "")));
} else if (option.input === "number") {
input = numberInput("0.1", "0");
input.value = String(preset ?? "");
@@ -234,67 +234,70 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
/** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */
const pushMarks = (): void =>
deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes);
const section = createStructuresSection({
onChange: (next) => {
structures = withLocalIds(next);
section.setStructures(structures);
pushMarks();
if (deps.projectId) writePendingStructures(deps.projectId, structures);
deps.onStructuresChanged?.();
},
onSelect: (structure) => {
if (structure) deps.focusChainage(structureAnchorM(structure));
},
getInterval: deps.stationInterval,
onReveal: () => deps.reveal?.(),
onPipeAdd: () => showToast(PIPE_ADD_GUIDE, "error"),
// 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 →
// [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다.
onPipeUpdate: (fromChainageM, toChainageM, attributes) => {
// 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는
// 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다
// (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다).
const moved = Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M;
if (moved) {
if (deps.movePipe) {
deps.movePipe(fromChainageM, toChainageM);
const hit = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
);
if (hit) hit.chainage_m = toChainageM;
currentChainageM = toChainageM;
section.setPipeFacilities(pipeFacilities);
pushMarks();
showToast(PIPE_MOVE_NOTICE, "success");
} else {
showToast(PIPE_MOVE_GUIDE, "error");
const section = createStructuresSection(
{
onChange: (next) => {
structures = withLocalIds(next);
section.setStructures(structures);
pushMarks();
if (deps.projectId) writePendingStructures(deps.projectId, structures);
deps.onStructuresChanged?.();
},
onSelect: (structure) => {
if (structure) deps.focusChainage(structureAnchorM(structure));
},
getInterval: deps.stationInterval,
onReveal: () => deps.reveal?.(),
onPipeAdd: () => showToast(PIPE_ADD_GUIDE, "error"),
// 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 →
// [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다.
onPipeUpdate: (fromChainageM, toChainageM, attributes) => {
// 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는
// 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다
// (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다).
const moved = Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M;
if (moved) {
if (deps.movePipe) {
deps.movePipe(fromChainageM, toChainageM);
const hit = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
);
if (hit) hit.chainage_m = toChainageM;
currentChainageM = toChainageM;
section.setPipeFacilities(pipeFacilities);
pushMarks();
showToast(PIPE_MOVE_NOTICE, "success");
} else {
showToast(PIPE_MOVE_GUIDE, "error");
}
}
}
const patch = attributes.options;
if (!patch || !Object.keys(patch).length) return;
if (!deps.queuePipeOptions) {
showToast(PIPE_ADD_GUIDE, "error");
return;
}
// 단 수는 옵션이자 **조작 채널**이다 — 조정창에서 세우던 그 자리로 보내야
// 횡단도에 단이 선다(2026-08-30 사용자 지시 3: 배수관 측점과 동일하게).
// 옵션은 **옮기기 전 자리**로 예약한다 — 저장 시점의 관 목록이 그 자리 기준이다.
deps.queuePipeOptions(fromChainageM, patch as Record<string, number | string>);
deps.applyPipeOptions?.(fromChainageM, patch as Record<string, number | string>);
// 화면 캐시(목록·폼 재로드용)도 같이 맞춘다 — 저장 전에도 값이 유지된다.
const hit = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
);
if (hit) hit.options = { ...(hit.options ?? {}), ...patch };
section.setPipeFacilities(pipeFacilities);
const patch = attributes.options;
if (!patch || !Object.keys(patch).length) return;
if (!deps.queuePipeOptions) {
showToast(PIPE_ADD_GUIDE, "error");
return;
}
// 단 수는 옵션이자 **조작 채널**이다 — 조정창에서 세우던 그 자리로 보내야
// 횡단도에 단이 선다(2026-08-30 사용자 지시 3: 배수관 측점과 동일하게).
// 옵션은 **옮기기 전 자리**로 예약한다 — 저장 시점의 관 목록이 그 자리 기준이다.
deps.queuePipeOptions(fromChainageM, patch as Record<string, number | string>);
deps.applyPipeOptions?.(fromChainageM, patch as Record<string, number | string>);
// 화면 캐시(목록·폼 재로드용)도 같이 맞춘다 — 저장 전에도 값이 유지된다.
const hit = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
);
if (hit) hit.options = { ...(hit.options ?? {}), ...patch };
section.setPipeFacilities(pipeFacilities);
},
onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"),
onPipeSelect: (chainageM) => {
// 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
writeStructurePick(deps.projectId, chainageM);
if (chainageM !== null) deps.focusChainage(chainageM);
},
},
onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"),
onPipeSelect: (chainageM) => {
// 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
writeStructurePick(deps.projectId, chainageM);
if (chainageM !== null) deps.focusChainage(chainageM);
},
});
{ includeDetail: true },
);
// 고른 것 없이 좌측 폼을 만지면 값이 아무 데도 가지 않는다(패널 실시간 반영이
// 선택된 항목에만 걸린다) — 조용히 무시되던 자리라 이유를 알린다(2026-08-30 사용자:
@@ -60,6 +60,38 @@ MASONRY_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry"
MASONRY_PREFIX = "masonry_class_"
#: 목재공작물 구조 갈래표 — 품셈 13-13-1 [주]③ 이 **재료 구성**으로 가른다.
TIMBER_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_timber"
TIMBER_PREFIX = "timber_structure_class_"
def load_timber_table(path: Path | None = None) -> dict[str, Any]:
"""목재공작물 갈래표. 파일이 없으면 빈 표 — 갈래가 안 붙고 그대로 드러난다."""
target = path
if target is None:
files = sorted(TIMBER_DIR.glob(TIMBER_PREFIX + "*.json")) if TIMBER_DIR.is_dir() else []
target = files[-1] if files else None
if target is None or not target.is_file():
return {}
return json.loads(target.read_text(encoding="utf-8"))
def timber_class(type_id: str, table: dict[str, Any] | None = None) -> tuple[str | None, str, bool]:
"""(갈래, 근거, 잠정인가). **잠정이면 그 사실을 숨기지 않는다.**
⚠ 갈래를 고르되 **드러낸다** — 임의로 고르고 조용히 넘어가면 미결을 숨기는 것이다.
밑수 1㎥ 는 **목재 채적**이라 「1㎥에 건축목공 17인」이 말이 된다(원문 [주]③).
"""
found = table if table is not None else load_timber_table()
for row in (found or {}).get("type_map") or []:
if row.get("type_id") == type_id and row.get("class"):
basis = f"품셈 13-13-1 [주]③ 「{row.get('matched')}"
if row.get("provisional"):
basis += f" · ⚠ 잠정 — {row.get('compare', '')}"
return str(row["class"]), basis, bool(row.get("provisional"))
return None, f"품셈 13-13-1 [주]③ 예시에 없는 공작물({type_id}) — 임의로 고르지 않음", False
def load_masonry_table(path: Path | None = None) -> dict[str, Any]:
"""돌쌓기 갈래표. 파일이 없으면 빈 표 — 갈래가 안 붙고 그대로 드러난다."""
target = path
+105 -14
View File
@@ -34,13 +34,59 @@ EROSION_CONTROL_TYPES = frozenset(
}
)
#: 규준틀 — **품셈 원문이 개소 기준을 정해 둔다**(2026-09-07 ㉒ 에서 찾음).
#: 11-2 [주]① 「비탈길이 **10m 이상** **20m마다** 설치한다」
#: 11-3 [주]① 「중심점에서 **성토 높이 5m 이상**에 설치한다」
#: ⚠ 재료량은 [주]④ 「설계수량에 따른다」 — **개소만 내고 재료는 미확보**로 둔다.
BATTER_MIN_SLOPE_LENGTH_M = 10.0
BATTER_INTERVAL_M = 20.0
LEVEL_MIN_FILL_HEIGHT_M = 5.0
STATUS_COUNTED_ELSEWHERE = "다른 표에서 이미 섬"
STATUS_PENDING = "값을 낼 근거가 없음"
STATUS_NOT_APPLICABLE = "해당 없음"
STATUS_READY = "값 있음"
def preparation_rows(slope_totals: dict[str, float] | None = None) -> list[dict[str, Any]]:
def batter_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int, list[str]]:
"""비탈 규준틀 개소 — **비탈길이 10m 이상인 구간에서 20m마다**(품셈 11-2 [주]①).
⚠ 「10m 이상」은 **비탈길이**(사면길이) 조건이고 「20m마다」는 **노선 거리** 간격이다.
둘을 섞지 않는다 — 사면이 긴 구간의 **연장**을 20m 로 나눈다.
"""
length_m = 0.0
notes: list[str] = []
for row in slope_rows:
lengths = row.get("lengths") or {}
# 그 측점의 사면길이는 계열마다 있으나 **가장 긴 것**으로 본다(같은 사면이다).
longest = max((float(v) for v in lengths.values()), default=0.0)
if longest >= BATTER_MIN_SLOPE_LENGTH_M:
length_m += float(row.get("distance_m") or 0.0)
if length_m <= 0:
return 0, ["비탈길이 10m 이상인 구간이 없어 비탈 규준틀이 서지 않음"]
count = int(length_m // BATTER_INTERVAL_M) + 1
notes.append(
f"비탈길이 {BATTER_MIN_SLOPE_LENGTH_M:g}m 이상 구간 {length_m:g}m ÷ "
f"{BATTER_INTERVAL_M:g}m + 1 (품셈 11-2 [주]①)"
)
return count, notes
def level_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int, list[str]]:
"""수평 규준틀 — **중심점 성토 높이 5m 이상**에 설치(품셈 11-3 [주]①).
⚠ 성토고는 사면표에 없다 — 그 값을 받기 전에는 **0 으로 때우지 않고 미확보**로 둔다.
"""
return 0, [
"중심점 성토고 5m 이상 지점에 설치(품셈 11-3 [주]①)인데 성토고가 이 표에 없어 개소를 못 셈"
]
def preparation_rows(
slope_totals: dict[str, float] | None = None,
slope_rows: Iterable[dict[str, Any]] = (),
topsoil_thickness_m: float | None = None,
) -> list[dict[str, Any]]:
"""준비공 줄 — 값이 서는 것과 안 서는 것을 **한 목록에** 낸다."""
slope = slope_totals or {}
tree_area = float(slope.get("tree_removal_fill", 0.0)) + float(
@@ -62,15 +108,7 @@ def preparation_rows(slope_totals: dict[str, float] | None = None) -> list[dict[
),
"work_item_code": None,
},
{
"group": "준비공",
"item": "표토제거",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": "면적은 사면적에서 나오나 **두께·대상 구간**이 설계로 안 정해져 있음 (품셈 9-15)",
"work_item_code": "FP-09-15",
},
_topsoil_row(slope, topsoil_thickness_m),
{
"group": "준비공",
"item": "제근·뿌리다듬기",
@@ -80,18 +118,67 @@ def preparation_rows(slope_totals: dict[str, float] | None = None) -> list[dict[
"reason": "단위가 「개」(그루 수)인데 입목 본수를 들고 있지 않음 (품셈 9-20~21)",
"work_item_code": "FP-09-21",
},
_batter_frame_row(list(slope_rows)),
{
"group": "준비공",
"item": "규준틀",
"item": "수평 규준틀",
"unit": "개소",
"amount": None,
"status": STATUS_PENDING,
"reason": "개소 산정 기준(구조물·절성토 구간별 몇 개소)이 안 정해져 있음 (품셈 11-2)",
"work_item_code": "FP-11-02",
"reason": level_frame_count(())[1][0],
"work_item_code": "FP-11-03",
},
]
def _topsoil_row(slope: dict[str, float], thickness_m: float | None) -> dict[str, Any]:
"""표토제거 — **두께는 품셈이 아니라 설계가 정한다**(9-15 [주]② 「T : 표토두께(m)」).
⚠ 두께를 안 넣으면 **0 으로 때우지 않고** 물량을 안 낸다. 대상 면적은 절·성토 사면적을
쓴다(사면 계열의 면고르기 면적과 같은 자리).
"""
area = float(slope.get("face_dressing_fill", 0.0)) + float(slope.get("face_dressing_cut", 0.0))
if thickness_m is None or float(thickness_m) <= 0:
return {
"group": "준비공",
"item": "표토제거",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": (
"표토 두께가 아직 입력되지 않았습니다 — 품셈 9-15 [주]② 가 두께를 "
"「공식의 입력 변수(T)」로 두어 **품셈이 정하는 값이 아닙니다**. "
f"산출 조건에서 두께를 넣으면 값이 섭니다 (대상 면적 {area:,.1f}㎡)"
),
"reference_amount": area,
"work_item_code": "FP-09-15",
}
thickness = float(thickness_m)
return {
"group": "준비공",
"item": "표토제거",
"unit": "",
"amount": area * thickness,
"status": STATUS_READY,
"reason": f"사면적 {area:,.1f}× 두께 {thickness:g}m (품셈 9-15)",
"work_item_code": "FP-09-15",
}
def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]:
"""비탈 규준틀 한 줄. **개소는 원문 기준으로 서고 재료는 미확보**다."""
count, notes = batter_frame_count(slope_rows)
return {
"group": "준비공",
"item": "비탈 규준틀",
"unit": "개소",
"amount": float(count) if count else None,
"status": STATUS_READY if count else STATUS_PENDING,
"reason": ("; ".join(notes) + " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」라 미확보"),
"work_item_code": "FP-11-02",
}
def erosion_rows(structures: Iterable[dict[str, Any]] = ()) -> list[dict[str, Any]]:
"""사방공 줄 — 이 노선에 사방 시설이 **있을 때만** 값이 선다."""
found = sorted(
@@ -130,9 +217,13 @@ def erosion_rows(structures: Iterable[dict[str, Any]] = ()) -> list[dict[str, An
def build_table(
slope_totals: dict[str, float] | None = None,
structures: Iterable[dict[str, Any]] = (),
slope_rows: Iterable[dict[str, Any]] = (),
topsoil_thickness_m: float | None = None,
) -> dict[str, Any]:
"""화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**"""
rows = preparation_rows(slope_totals) + erosion_rows(structures)
rows = preparation_rows(slope_totals, slope_rows, topsoil_thickness_m) + erosion_rows(
structures
)
return {
"columns": ["구분", "공종", "단위", "수량", "상태", "사유"],
"rows": rows,
@@ -100,7 +100,10 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
)
# 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨).
table["preparation"] = build_preparation_table(
slope.get("totals") or {}, await _route_structures(project_id)
slope.get("totals") or {},
await _route_structures(project_id),
slope.get("rows") or [],
settings.get("topsoil_thickness_m"),
)
method, method_is_default = concrete_placing_method(settings)
# ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다.
@@ -179,6 +182,8 @@ class QuantitySettingsBody(BaseModel):
material_supply: dict[str, Any] | None = None
# 콘크리트 타설 방식. `""` 는 「안 정함」으로 되돌리는 뜻이라 서버가 None 으로 만든다.
concrete_placing_method: str | None = None
# 표토제거 두께(m). 품셈이 정하는 값이 아니라 설계 입력이다(9-15 [주]②).
topsoil_thickness_m: float | None = None
@router.put("/{project_id}/quantity/settings")
@@ -92,6 +92,10 @@ def default_settings() -> dict[str, Any]:
# 또는 `{자재명: {"supply": …, "install_by": "contractor"|"owner"}}`.
# ⚠ **법이 아니라 발주 결정**이라 기본은 비워 둔다. 안 정한 자재는 「미분류」로
# 화면에 드러난다 — 사급으로 조용히 넘기면 관급자재대가 새 나간다.
# 표토제거 두께(m) — ⚠ **품셈이 정하는 값이 아니다.** 9-15 [주]② 가
# 「T : 표토두께(m)」로 **공식의 입력 변수**로 두었다(2026-09-07 원문 확인).
# 기본값을 두지 않는다 — 안 넣으면 물량을 안 낸다(0 으로 때우지 않음).
"topsoil_thickness_m": None,
"material_supply": {},
# 콘크리트 타설 방식 — `ready_mixed`(FP-12-01-01) / `machine_mixed`(-02) /
# `hand_mixed`(-03). **설계 판단**이라 사용자가 고른다.
@@ -0,0 +1,80 @@
{
"schema_version": "1.0",
"dataset_id": "timber_structure_class",
"effective_date": "2026-01-01",
"note": "목재공작물 구조 갈래(품셈 13-13-1 [주]③). **재료 구성**으로 가르며 원문이 예시를 든다. 구조물 종류가 늘면 `type_map` 에 줄만 더한다.",
"source": {
"doc": "산림사업 표준품셈 13-13-1 목재틀흙막이 [주]③",
"table_id": "F0440",
"quote": "보통구조 : 통나무나 대각재, 후판 등이 대부분(80%) 이상으로, 목재 채적에 비해 가공정도가 적은 공작물 (하: 통나무 경계목 / 중: 통나무 방풍책 / 상: 통나무 기슭막이) · 중등구조 : 통나무나 대각재, 후판 등이 절반(50%) 이상 (보통: 통나무 바닥막이·누구막이 / 상: 통나무 골막이) · 상등구조 : 소각재, 박판, 소폭판 등이 목재의 50% 이상 (통나무 사방댐 등)"
},
"basis_unit": {
"note": "⚠ 밑수 1㎥ 는 **구조물 체적이 아니라 목재 채적**이다. 원문이 「목재 채적에 비해 가공정도」로 갈래를 가르는 데서 드러난다. 그래야 「1㎥에 건축목공 17인」이 말이 된다.",
"unit": "목재 채적 ㎥"
},
"classes": [
{
"key": "보통구조 하",
"carpenter": 6.285,
"laborer": 0.682,
"examples": [
"통나무 경계목"
]
},
{
"key": "보통구조 중",
"carpenter": 7.274,
"laborer": 0.786,
"examples": [
"통나무 방풍책"
]
},
{
"key": "보통구조 상",
"carpenter": 8.76,
"laborer": 0.958,
"examples": [
"통나무 기슭막이"
]
},
{
"key": "중등구조 보통",
"carpenter": 10.612,
"laborer": 1.156,
"examples": [
"통나무 바닥막이",
"누구막이"
]
},
{
"key": "중등구조 상",
"carpenter": 13.767,
"laborer": 1.497,
"examples": [
"통나무 골막이"
]
},
{
"key": "상등구조",
"carpenter": 16.975,
"laborer": 1.848,
"examples": [
"통나무 사방댐"
]
}
],
"type_map": [
{
"type_id": "soil_guard",
"class": "보통구조 상",
"matched": "통나무 기슭막이",
"provisional": true,
"why": "임도 흙막이는 통나무를 짜 맞춘 틀이고 **소각재·박판·소폭판이 목재의 50 % 이상**이라는 상등구조 조건을 안 채운다. 원문 예시로도 「통나무 기슭막이」가 같은 급이고 「통나무 사방댐」은 사방 구조물이다.",
"compare": "상등구조를 쓰면 건축목공 8.760 → 16.975 인/㎥ 로 약 1.9배"
}
],
"pending_user": {
"note": "⚠ 위 판정은 **잠정**이다. 사용자가 예/아니오로 답할 수 있게 물음을 좁혀 둔다.",
"question": "임도 흙막이의 목재공작물 구조 갈래가 「보통구조 상(통나무 기슭막이 급)」이 맞습니까?"
}
}