Merge remote-tracking branches 'origin/main_desktop_1' and 'origin/main_laptop_1' into sub_desktop_1

This commit is contained in:
2026-09-14 09:02:25 +09:00
43 changed files with 1596 additions and 92 deletions
@@ -332,6 +332,26 @@
"unit": "%",
"default": null,
"required": false
},
{
"key": "thickness_cm",
"label": "포장 두께",
"input": "number",
"unit": "㎝",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 표가 안 서고 「두께를 적어야 섬」 사유가 뜸 — 두께로 관측 원단위(울진 20㎝)를 고름(2026-09-14 A3)"
},
{
"key": "length_m",
"label": "포장 길이(노폭 방향)",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 면적(월류 폭 × 길이)이 0 이라 표가 안 서고 사유가 뜸 — 노견까지 전폭을 적음(2026-09-14 A3)"
}
]
},
@@ -479,6 +499,15 @@
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎"
},
{
"key": "length_m",
"label": "연장",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"empty_means": "비우면 표가 안 서고 「연장을 적어야 섬」 사유가 뜸 — 개거는 m당 원단위라 연장이 곧 밑수(2026-09-14 A2)"
}
]
},
+18 -24
View File
@@ -45,7 +45,10 @@ import {
fordSection,
grid,
group,
INLET_KINDS,
INLET_REVET_KEYS,
inletKindOf,
type InletStructureKind,
labeled,
numberInput,
optionalSelect,
@@ -57,29 +60,8 @@ import {
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
/** B06 유입측 구조물 형식 — 조정창 드롭다운과 같은 값 집합(2026-08-29 병합).
* 문자열 리터럴로 두어 B05가 B06 모듈에 기대지 않게 한다. */
export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U";
/** 유입구 "구조" 목록 — 조정창의 구조물 형식(자동/기슭막이/집수정 I·ㄴ·ㄷ)을 이 한
* 드롭다운으로 합쳤다(2026-08-29 사용자 지시 5: 일단 리스트를 합치고 중복은 뒤에 뺀다).
* 고른 값 하나가 정본 둘을 정한다 — 관 옵션 `inlet_type`(기슭막이/집수정)과 B06
* 유입측 형식(`inlet_structure`). */
const INLET_KINDS: ReadonlyArray<{
label: string;
type: "기슭막이" | "집수정";
structure: InletStructureKind;
}> = [
{ label: "기슭막이", type: "기슭막이", structure: "revet" },
{ label: "집수정", type: "집수정", structure: "auto" },
{ label: "자동(규칙)", type: "기슭막이", structure: "auto" },
{ label: "집수정 I형", type: "집수정", structure: "I" },
{ label: "집수정 ㄴ형", type: "집수정", structure: "L" },
{ label: "집수정 ㄷ형", type: "집수정", structure: "U" },
];
const inletKindOf = (label: string): (typeof INLET_KINDS)[number] =>
INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0];
// 유입구 형식 목록은 700줄 한계로 `_Fields` 로 옮김(2026-09-14) — 부르던 곳은 그대로 여기서.
export type { InletStructureKind };
/** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을
* 가지면 저장한다. */
@@ -382,7 +364,15 @@ export function createFacilityOptionsForm(
// 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다.
const fordSlope = numberInput("0.1");
fordSlope.placeholder = "노면 기울기";
const fordSlopeRow = grid(labeled("바닥 경사 유입→유출 (%)", stepper(fordSlope, 0.1)));
// 포장 두께·노폭 방향 길이 — 수량(㎡ = 월류 폭 × 길이 · 두께로 원단위 고름)이 읽는 칸(2026-09-14 A3).
// 비우면 표가 안 서고 그 까닭이 뜸 — 지어내지 않는다.
const fordThickness = numberInput("1");
const fordLength = numberInput("0.1");
const fordSlopeRow = grid(
labeled("바닥 경사 유입→유출 (%)", stepper(fordSlope, 0.1)),
labeled("포장 두께 (㎝)", stepper(fordThickness, 1, 0)),
labeled("포장 길이 노폭 방향 (m)", stepper(fordLength, 0.1)),
);
const fordSummary = document.createElement("p");
fordSummary.className = "b05-drainage__facility-note";
/** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */
@@ -634,6 +624,8 @@ export function createFacilityOptionsForm(
// 새 시설을 올리는 참이다 — 저장된 높이는 사용자 값으로 보고 자동 추적을 끊는다.
fordAutoDepthM = null;
fordSlope.value = text("ford_slope_pct");
fordThickness.value = text("thickness_cm");
fordLength.value = text("length_m");
revetSide.value = text("side") || "양쪽";
const spread = legacyRevetOptions(options);
revetInlet.fields.write(spread);
@@ -679,6 +671,8 @@ export function createFacilityOptionsForm(
putNumber(options, "ford_width_m", fordWidth.value);
putFordHeight(options);
putNumber(options, "ford_slope_pct", fordSlope.value);
putNumber(options, "thickness_cm", fordThickness.value);
putNumber(options, "length_m", fordLength.value);
} else if (current === "ford_bridge") {
options.pipe_kind = pipeMaterial.value;
options.pipe_diameter_mm = Number(pipeDiameter.value);
@@ -10,6 +10,32 @@
import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend";
/* ── 유입구 구조 ──────────────────────────────────────────────────────────── */
/** B06 유입측 구조물 형식 — 조정창 드롭다운과 같은 값 집합(2026-08-29 병합).
* 문자열 리터럴로 두어 B05가 B06 모듈에 기대지 않게 한다. */
export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U";
/** 유입구 "구조" 목록 — 조정창의 구조물 형식(자동/기슭막이/집수정 I·ㄴ·ㄷ)을 이 한
* 드롭다운으로 합쳤다(2026-08-29 사용자 지시 5: 일단 리스트를 합치고 중복은 뒤에 뺀다).
* 고른 값 하나가 정본 둘을 정한다 — 관 옵션 `inlet_type`(기슭막이/집수정)과 B06
* 유입측 형식(`inlet_structure`). */
export const INLET_KINDS: ReadonlyArray<{
label: string;
type: "기슭막이" | "집수정";
structure: InletStructureKind;
}> = [
{ label: "기슭막이", type: "기슭막이", structure: "revet" },
{ label: "집수정", type: "집수정", structure: "auto" },
{ label: "자동(규칙)", type: "기슭막이", structure: "auto" },
{ label: "집수정 I형", type: "집수정", structure: "I" },
{ label: "집수정 ㄴ형", type: "집수정", structure: "L" },
{ label: "집수정 ㄷ형", type: "집수정", structure: "U" },
];
export const inletKindOf = (label: string): (typeof INLET_KINDS)[number] =>
INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0];
/* ── 물넘이·세월교 개략 단면 ────────────────────────────────────────────── */
/** 월류 단면 한 벌 — 설계유량을 흘리는 데 **필요한** 수심·단면적과 그때의 유속. */
@@ -0,0 +1,73 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Facility_Merge.ts
* 계곡 통과 시설 [저장] — **폼이 아는 칸만 갈아 끼우고, 폼이 모르는 칸은 건드리지 않음**
* (2026-09-14 브레인 판정).
*
* ⚠ 앞서 저장이 옵션을 **통째로** 갈아 끼웠음 — 구조물 집계표·구조물도로 적은 칸(관 기슭막이
* 기초 `revet_foundation` · 독립 기슭막이 `foundation`·뒷길이·돌 종류 …)이 B05 에서 그 시설을
* 한 번 더 저장하면 **조용히 지워졌음**. 「작업본 쓰기는 [저장]에서만」인데 그 [저장]이 옆 칸까지
* 지우던 자리.
* ⚠ 통째로 갈아 끼우는 것은 **시설 종류 자체를 바꿀 때만** — 그때는 옛 칸이 뜻을 잃음.
* ⚠ 폼이 아는 칸을 폼이 비워 보내면(값을 지움·유입구를 집수정 → 기슭막이로 바꿔 집수정 칸이 빠짐)
* 그 칸은 지워짐 — 사용자가 폼에서 한 일이므로 맞음.
* ⚠ 폼에 칸을 늘리면 아래 목록에도 넣을 것. 빠뜨리면 그 칸을 **폼에서 지워도 옛 값이 남는**
* 쪽으로 틀림(지워지는 쪽보다 덜 위험). 거울 시험 `test_b05_facility_options_merge`.
* ⚠ import 없음 — 시험이 이 파일만 옮겨 돌림.
* ========================================================================== */
const revetKeys = (side: "inlet" | "outlet"): string[] =>
["form", "length_m", "height_m", "before_m", "after_m"].map((key) => `${side}_revet_${key}`);
const wingKeys = (side: "in" | "out"): string[] => [
`wing_${side}`,
`wing_${side}_height_m`,
`wing_${side}_length_m`,
`wing_${side}_angle_deg`,
];
/** 시설 종류별로 **폼이 적는 칸** — `createFacilityOptionsForm().readOptions()` 가 쓰는 키 전부. */
export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
pipe: [
"pipe_diameter_mm",
"pipe_kind",
"wing_wall_type",
"inlet_type",
"inlet_structure",
"inlet_basin_form",
"inlet_basin_material",
"inlet_basin_length_m",
...revetKeys("inlet"),
"outlet_type",
...revetKeys("outlet"),
],
box_culvert: ["body_width_m", "body_height_m", ...wingKeys("in"), ...wingKeys("out")],
ford_pavement: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"],
ford_bridge: [
"pipe_kind",
"pipe_diameter_mm",
"pipe_count",
"ford_width_m",
"ford_height_m",
...wingKeys("in"),
...wingKeys("out"),
],
revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")],
};
interface MergeableAttributes {
facility: string;
start_m?: number;
end_m?: number;
options?: Record<string, string | number>;
}
/** 저장할 시설 정보 — 같은 종류면 폼이 모르는 옛 칸을 이어 붙임. */
export function mergeFacilityOptions<T extends MergeableAttributes>(
previous: T | null,
next: T,
): T {
if (!previous || previous.facility !== next.facility) return next;
const managed = new Set(FORM_OPTION_KEYS[next.facility] ?? []);
const kept = Object.entries(previous.options ?? {}).filter(([key]) => !managed.has(key));
const options = { ...Object.fromEntries(kept), ...(next.options ?? {}) };
return { ...next, options: Object.keys(options).length ? options : undefined };
}
+4 -1
View File
@@ -30,6 +30,7 @@ import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp
import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples";
import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
@@ -607,8 +608,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
pipeEditor.addAtChainage(chainageM);
},
updatePipeFacility(fromChainageM, toChainageM, attributes) {
// ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임.
const previous = facilityStore.get(fromChainageM);
facilityStore.set(fromChainageM, null);
facilityStore.set(toChainageM, attributes);
facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes));
if (Math.abs(fromChainageM - toChainageM) > 0.005) {
// 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고,
// attach가 새 위치로 시설 정보를 승계한다.
+2 -1
View File
@@ -54,6 +54,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import (
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( # noqa: F401
BLOCKED_FORMULA_MISSING,
BLOCKED_INPUT_MISSING,
BLOCKED_UNCONFIRMED,
BLOCKED_UNIT_DATA_MISSING,
METHOD_TO_GROUND,
NOTE_METHOD_MISSING,
@@ -185,7 +186,7 @@ def build_handoff(
str(row.get("ground_class"))
for row in work_items
if row.get("ground_class")
and row.get("ground_class") != "토사"
and row.get("ground_class") not in ("토사", "") # 「암」은 구성비가 빠진 것
and row.get("work_item_code") is None
and row.get("origin") == ORIGIN_EARTHWORK
}
@@ -121,6 +121,12 @@ ORIGIN_PIPE = "pipe"
#: 갈래 이름(연암·보통암…)만으로는 공종을 못 고른다(2026-09-07 일감 9 실서버에서 드러남).
METHOD_TO_GROUND = {"ripping": "리핑암", "blasting": "발파암"}
NOTE_METHOD_MISSING = "시공법 미지정으로 공종을 못 고름"
#: 암 총량이 갈래로 안 나뉘어 한 줄 「암」으로 선 경우 — 시공법은 **갈래마다** 고르므로 이 줄은
#: 시공법만 골라서는 안 풀린다. 빠진 입력은 구성비다(2026-09-14 흙깎기·측구터파기 암).
NOTE_ROCK_RATIO_MISSING = (
"암 갈래 구성비(%)가 아직 없어 암 총량이 한 줄 「암」으로 섬 — 산출 조건 「암 갈래 구성비」를 "
"넣고 갈래마다 시공법(리핑·발파)을 고르면 공종이 섬"
)
#: 철근으로 보는 성분 이름 조각. **정확한 낱말이 아니라 앞머리**로 본다 —
#: 「이형철근 D13」·「원형철근」처럼 규격이 뒤에 붙기 때문이다. `철근콘크리트`는 성분 이름이
@@ -132,6 +138,9 @@ REBAR_PREFIXES = ("이형철근", "원형철근", "철근")
BLOCKED_INPUT_MISSING = "input_missing" # 저장 제원 칸이 비어 있음 — 입력하면 풀림
BLOCKED_UNIT_DATA_MISSING = "unit_data_missing" # 원단위·표준 물량 자료가 없음
BLOCKED_FORMULA_MISSING = "formula_missing" # 수량 산출식 자체가 없음
#: 치수가 없어 **등록부 기본값으로 선** 구조물 줄(2026-09-14 브레인 판정) — 수량은 보이되 금액·합계엔
#: 안 듦. B09 가 내역 제자리에 빈 금액 + 빨간 테두리로 세우고 「미확정 N건 — 금액에 안 들어감」.
BLOCKED_UNCONFIRMED = "unconfirmed"
#: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다.
#: ⚠ `item` 칸이 **지반 갈래**인 공종 — 그 밖의 공종에서 `item` 은 **작업 갈래**다
@@ -13,12 +13,14 @@ from B08_Quantity.B08_Quantity_Engine_BasisUnit import normalize_unit, unit_for_
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
BLOCKED_FORMULA_MISSING,
BLOCKED_INPUT_MISSING,
BLOCKED_UNCONFIRMED,
BLOCKED_UNIT_DATA_MISSING,
GROUND_SPLIT_GROUPS,
HAUL_SUMMARY_GROUPS,
METHOD_TO_GROUND,
NOTE_HAUL_IN_SUMMARY,
NOTE_METHOD_MISSING,
NOTE_ROCK_RATIO_MISSING,
ORIGIN_EARTHWORK,
ORIGIN_HAUL,
ORIGIN_SLOPE,
@@ -33,6 +35,9 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
)
from B08_Quantity.B08_Quantity_Wording import type_label as wording_type_label
#: 기본값으로 선 구조물 줄 사유 머리 — B09 「미확정 N건 — 금액에 안 들어감」과 같은 말.
UNCONFIRMED_LABEL = "미확정 — 금액에 안 들어감"
def _spec_detail(structure: dict[str, Any]) -> str:
"""규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다."""
@@ -54,6 +59,8 @@ def _mapping_ground(ground: str | None, methods: dict[str, str | None]) -> tuple
"""
if ground is None or ground == "토사":
return ground, ""
if ground == "":
return None, NOTE_ROCK_RATIO_MISSING # 갈래로 안 나뉜 총량 — 시공법만으론 안 풀림
method = methods.get(ground)
mapped = METHOD_TO_GROUND.get(method or "")
if mapped:
@@ -281,9 +288,28 @@ def _haul_rows(
"origin": ORIGIN_HAUL,
}
)
if equipment == "dump_truck" and in_bill:
# 덤프 운반은 **싣는 일이 따로** — 산림품셈 10-12 「1. 적재」
# (굴착기 0.7㎥ · ㎝ 22초·180°).
# 흙깎기 9-3-2(㎝ 20초·135°, 깎아 옆에 둠)에 안 들어 있어 운반과 한 벌로 짝 줄을 냄
# (2026-09-14 브레인 판정). 거리와 무관 · 수량은 운반량 그대로.
rows.append(
{
**rows[-1],
"name": "덤프 적재",
"haul_distance_m": None,
"haul_equipment": LOADING_EQUIPMENT,
"spec_detail": f"{state_note} · 덤프 운반량만큼 싣기(10-12 「1. 적재」)",
}
)
return rows, unmatched
#: 인계 「덤프 적재」 짝 줄의 수단 표지 —
#: B09 `MachineProductivity_Dump.LOADING_EQUIPMENT` 와 같은 글.
LOADING_EQUIPMENT = "dump_loading"
def blocked_of(
structure: dict[str, Any], class_basis: str = "", has_code: bool = False
) -> tuple[str | None, str]:
@@ -378,6 +404,11 @@ def _structure_rows(
"큰돌쌓기 쌓기 방식이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 "
"메쌓기·찰쌓기 중 하나를 고르면 공종이 정해집니다"
)
if entry.get("class_from") == "form":
# 기슭막이 — **형태**가 공종을 가름(돌쌓기 찰·메). 그 밖 형태는 전개 사유가 막음.
form = str((structure.get("options") or {}).get("form") or "").strip()
code = (entry.get("form_codes") or {}).get(form)
class_basis = f"형태 「{form}」 → {code}" if code else ""
if code and entry.get("class_from") == "back_length":
class_key, class_basis = masonry_class(structure.get("options") or {})
@@ -442,6 +473,13 @@ def _structure_rows(
if notes
else "물량이 0 이라 내역에 안 세움 — 저장 제원에서 치수·면적을 넣으면 값이 섭니다"
)
# ⭐ 치수가 없어 기본값으로 선 구조물(2026-09-14 브레인 판정) — **줄은 서되 금액은 안 듦.**
# B09 가 내역 제자리에 빈 금액 + 빨간 테두리로 세우고 「미확정 N건 — 금액에 안 들어감」.
unconfirmed = str(structure.get("unconfirmed") or "")
if unconfirmed:
in_bill = False
zero_reason = f"{UNCONFIRMED_LABEL}{unconfirmed}"
blocked_kind, blocked_reason = BLOCKED_UNCONFIRMED, zero_reason
rows.append(
{
"work_item_code": code,
@@ -486,7 +524,7 @@ def _structure_rows(
"origin": ORIGIN_STRUCTURE,
}
)
if sheet_entry is not None:
if sheet_entry is not None and not unconfirmed:
rows[-1] = template_row(rows[-1], structure, sheet_entry)
return rows, unmatched
@@ -536,8 +574,10 @@ def _placing_rows(
# 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 —
# 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.**
# ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다.
if mapping.composite_for(str(structure.get("type_id") or "")):
continue
if mapping.composite_for(str(structure.get("type_id") or "")) or structure.get(
"unconfirmed"
):
continue # 기본값으로 선 구조물도 — 금액에 안 듦
# ⚠ 버림은 **따로 센다** — 실무 내역이 「레미콘타설(장비) **무근,버림**」으로 갈라
# 적는다(봉화 제50호표, 2026-09-09 데스크탑 보조 확인). 같은 공종·같은 단가라
# 금액은 안 움직이고 **이름만 맞추는 것**이다.
@@ -166,8 +166,10 @@ def rubble_base_rows(
total = 0.0
bases: list[str] = []
for structure in unit_quantity_table.get("structures") or []:
if mapping.composite_for(str(structure.get("type_id") or "")):
continue
if mapping.composite_for(str(structure.get("type_id") or "")) or structure.get(
"unconfirmed"
):
continue # 묶음 조각이 품음 · 기본값으로 선 구조물은 금액에 안 듦
sheet_entry = priced.get(str(structure.get("structure_id")))
if sheet_entry is not None and RUBBLE_GROUP in sheet_entry["covered"]:
continue
@@ -219,6 +221,8 @@ def structure_earthwork_rows(
# 사유도 안 났다). 관측 원단위로 가는 종류는 표에 터파기 줄이 없으면 이렇게 된다.
without_trench: list[str] = []
for structure in unit_quantity_table.get("structures") or []:
if structure.get("unconfirmed"):
continue # 기본값으로 선 구조물 — 제 줄에 사유가 섬, 토공 금액엔 안 듦
options = structure.get("options") or {}
height = float(structure.get("height_m") or options.get("height_m") or 0.0)
# 단면으로 판 깊이가 있으면 그것(옹벽은 기초분만 팜 · 비탈분 제외) — 없으면 직고 + 기초 깊이.
@@ -243,7 +247,8 @@ def structure_earthwork_rows(
backfill += amount
elif name == "잔토처리":
spoil += amount
if not any(
# 성분이 아예 없는 구조물(세월교 등 산출식 없음)은 제 줄이 이미 사유로 막힘 — 여기 안 적음.
if structure.get("components") and not any(
str(component.get("name") or "") == "터파기"
and float(component.get("amount") or 0.0) > 0
for component in structure.get("components") or []
@@ -97,6 +97,8 @@ def haul_inputs(unit_quantity_table: dict[str, Any] | None) -> dict[str, Any]:
stone_by_ground: dict[str, float] = {}
stone_unknown = 0.0
for structure in unit_quantity_table.get("structures") or []:
if structure.get("unconfirmed"):
continue # 기본값으로 선 구조물 — 잔토·채집석이 사토 운반 금액으로 번지지 않게
amount = 0.0
stone = 0.0
for component in structure.get("components") or []:
@@ -286,6 +286,10 @@ def _supply_setting(supply: dict[str, Any], row: MaterialRow) -> Any:
return None
#: 건너뛴 까닭 칸 — 치수가 없어 기본값으로 선 구조물(구조물 수로 셈).
UNCONFIRMED_SKIP = "미확정(기본값으로 선 구조물)"
def _collect(
unit_quantity_table: dict[str, Any],
) -> tuple[dict[tuple[str, str, str], MaterialRow], dict[str, int]]:
@@ -293,6 +297,10 @@ def _collect(
rows: dict[tuple[str, str, str], MaterialRow] = {}
skipped: dict[str, int] = {}
for structure in unit_quantity_table.get("structures", []):
if structure.get("unconfirmed"):
# 기본값으로 선 구조물 — 자재에 안 듦(2026-09-14 브레인 판정 「없으면 없다고 보이기」).
skipped[UNCONFIRMED_SKIP] = skipped.get(UNCONFIRMED_SKIP, 0) + 1
continue
label = str(structure.get("name") or structure.get("type_id") or "")
for component in structure.get("components", []):
destination = str(component.get("destination") or "") or "(없음)"
@@ -273,7 +273,12 @@ def expand_observed(
if scale <= 0:
from B08_Quantity.B08_Quantity_Wording import type_label
return [], [f"{type_label(type_id)}의 연장·면적이 0 이라 물량을 내지 않았습니다"]
where = (
"면적(월류 폭 × 포장 길이)이 0 — 두 칸을 적으면 섬"
if found.get("unit") == ""
else "연장이 0 — 연장을 적으면 섬"
)
return [], [f"{type_label(type_id)}{where}"]
source_key = str(found.get("source") or "")
options = structure.get("options") or {}
+143 -3
View File
@@ -16,9 +16,9 @@
기슭막이가 같은 파일에 있다. `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다**
(실측: `5601e828` 11 2점이 `facility: ford_bridge`).
**유출·유입부 기슭막이는 여기서 않는다.**
옵션(`outlet_revet_*`) 정본이 구조물 목록에서는 빠졌(2026-08-28 이관).
구조물 으로 이중계상이다.
**유출·유입부 기슭막이는 줄에 않는다 `facility_structures` 원단위 전개로 .**
옵션(`outlet_revet_*`) 정본이다(2026-08-28 이관). 부설(품셈 12-11 m당: ·기초콘크리트·
거푸집) 기슭막이 몫이 없어 겹치지 않는다. 2026-09-14 까지는 **어느 있었다**(A1).
**터파기·되메우기를 줄에 붙이지 않는다.**
부설과 굴착이 각각 오면 **같은 굴착을 ** 센다(B09 가드와 같은 자리).
@@ -192,3 +192,143 @@ def build_rows(
"ready_count": sum(1 for row in rows if row["in_bill"]),
"length_total_m": round(sum(row["quantity"] for row in rows if row["in_bill"]), 3),
}
INLET_BASIN = "집수정"
#: 기슭막이 벽 칸 — 관·독립 기슭막이 모두 `{쪽}_revet_{칸}` · 독립의 옛 저장분은 `{칸}`(B06 `_revet_side`).
REVET_FIELDS = (
("form", "형태"),
("height_m", "높이"),
("length_m", "길이"),
("before_m", ""),
("after_m", ""),
)
#: (저장 채널, 관에 딸린 줄 이름 꼬리, 독립 기슭막이 줄 이름 꼬리)
REVET_ROLES = (
("inlet", "유입부 기슭막이", "유입 칸 벽"),
("outlet", "유출부 기슭막이", "유출 칸 벽"),
)
#: ⚠ 「횡단도와 같은 값」이 아님 — B06 은 관 벽 높이를 **관경 기준 최소 높이**로 따로 그림
#: (`revetWallSpec`), 독립 기슭막이도 저장 높이가 없으면 근입 깊이로 그림. 형태·길이만 같은 기본값.
NOTE_DEFAULT_WALL = (
"⚠ 시설 지점에 {filled} 을 안 적어 등록부 기본값으로 섰음 — 미확정이라 금액에 안 들어감"
" · 횡단도 벽 높이는 관경 기준으로 따로 그려져 다를 수 있음"
)
#: 금액에서 뺄 까닭(2026-09-14 브레인 판정) — 줄·표·그림은 서되 내역 합에는 안 듦.
UNCONFIRMED_WALL = "벽 치수({filled})가 시설 지점에 없어 기본값으로 섰음 — 적으면 금액이 섬"
NOTE_SIDE_UNKNOWN = (
"⚠ 설치 측 「{side}」인데 두 칸(유입·유출) 값이 달라 어느 칸이 그쪽인지 서버가 못 가림"
"(횡단 지형이 정함) — 값을 안 세움 · 두 칸을 같게 적으면 섬"
)
def _blank(value: Any) -> bool:
return value is None or value == ""
def _num_or_zero(value: Any) -> float:
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _revet_values(
options: dict[str, Any], role: str, defaults: dict[str, Any], legacy: bool
) -> tuple[dict[str, Any], list[str]]:
"""벽 한쪽 제원과 등록부 기본값으로 채운 칸 — B06 세트 스펙(`_side_spec`)과 같은 채움."""
values: dict[str, Any] = {}
filled: list[str] = []
for name, label in REVET_FIELDS:
value = options.get(f"{role}_revet_{name}")
if _blank(value) and legacy:
value = options.get(name)
if _blank(value):
value = defaults.get(name if legacy else f"{role}_revet_{name}")
if label and value is not None:
filled.append(f"{label} {value}")
values[name] = value
return values, filled
def facility_structures(points: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""계곡 통과 시설(`pipe_points.json`) → 원단위 전개가 읽는 구조물 줄 (A1, 2026-09-14).
자체는 `build_rows` 연장으로 .
적힌 칸은 **등록부 기본값** 사실을 사유로 붙이고 `unconfirmed` 금액 합에서
(2026-09-14 브레인 판정 줄은 서되 금액은 실제 값이 있을 때만).
집수정·기슭막이 터파기·되메우기는 전개 성분(`destination: earthwork`)이라 토공집계로만 .
"""
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B08_Quantity.B08_Quantity_Engine_StructureSummary import pipe_row_id
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import attachments_of, child_row
types = structure_type_map()
def defaults(type_id: str) -> dict[str, Any]:
found = types.get(type_id)
return {option.key: option.default for option in found.options} if found else {}
rows: list[dict[str, Any]] = []
for point in points or []:
facility = str(point.get("facility") or FACILITY_PIPE)
chainage = float(point.get("chainage_m") or 0.0)
options = dict(point.get("options") or {})
base = {
"structure_id": pipe_row_id(point),
"type_id": facility,
"chainage_m": chainage,
"start_m": point.get("start_m") if point.get("start_m") is not None else chainage,
"end_m": point.get("end_m") if point.get("end_m") is not None else chainage,
"options": options,
}
if facility not in (FACILITY_PIPE, "revetment"):
rows.append(base) # BOX암거·물넘이·세월교 — 전개식·관측값이 없으면 사유로 섬
continue
legacy = facility == "revetment" # 독립 기슭막이 — 벽 칸 밖의 제원(뒷길이 등)도 벽이 씀
wall_defaults = defaults(facility)
roles = list(REVET_ROLES)
inlet_notes: list[str] = []
if not legacy:
parent = {**base, "type_id": "pipe"}
children = [r for r in attachments_of(parent) if r["type_id"] != "pipe_inlet_basin"]
inlet = str(options.get("inlet_type") or wall_defaults.get("inlet_type") or "")
if inlet == INLET_BASIN:
# 형식을 안 골랐어도 줄은 세움 — 관측표가 「형식을 골라야 섬」 사유를 냄.
children.append(child_row(parent, "pipe_inlet_basin", "유입부 집수정"))
roles = roles[1:]
elif options.get("inlet_basin_form"):
inlet_notes.append(
f"⚠ 집수정 형식({options['inlet_basin_form']})이 적혀 있으나 유입구 구조가 "
f"{inlet}」라 집수정은 안 셈 — 유입구를 「집수정」으로 바꾸면 섬"
)
rows.extend(children)
walls = []
for role, pipe_label, own_label in roles:
values, filled = _revet_values(options, role, wall_defaults, legacy)
notes = list(inlet_notes) if role == "inlet" else []
if filled:
notes.append(NOTE_DEFAULT_WALL.format(filled=" · ".join(filled)))
walls.append([role, own_label if legacy else pipe_label, values, notes, False, filled])
side = str(options.get("side") or "") if legacy else ""
if side in ("", ""):
# 좌·우가 유입/유출 어느 칸인지는 **횡단 지형**이 정함 — 두 칸이 같을 때만 한 벽으로 셈.
same = walls[0][2] == walls[1][2]
walls = [[walls[0][0], f"{side}", walls[0][2], walls[0][3], not same, walls[0][5]]]
if not same:
walls[0][3].append(NOTE_SIDE_UNKNOWN.format(side=side))
foundation = options.get("foundation" if legacy else "revet_foundation")
kept = {k: v for k, v in options.items() if not k.startswith(("inlet_", "outlet_"))}
for role, label, values, notes, withheld, filled in walls:
before, after = _num_or_zero(values["before_m"]), _num_or_zero(values["after_m"])
row = child_row(base, "revetment", label, f"{role}_revet")
row.update(
start_m=chainage - before,
end_m=chainage + after,
options={**(kept if legacy else {}), **values, "foundation": foundation},
notes=notes,
withheld=withheld,
unconfirmed=UNCONFIRMED_WALL.format(filled=" · ".join(filled)) if filled else "",
)
rows.append(row)
return rows
@@ -26,6 +26,7 @@ from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Pipe import NOTE_DEFAULT_WALL
from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
_back_length,
@@ -283,9 +284,14 @@ def _masonry_figure(sheet: dict[str, Any]) -> list[dict[str, Any]]:
_LABEL_SIZE,
),
]
if default_note:
# 「이 값이 어디서 왔나」 — 표 사유 줄과 **글자까지 같게**(브레인 판정).
shapes.append(_text(default_note, (0.0, -0.80 - base_d), "left"))
# 「이 값이 어디서 왔나」 — 표 사유 줄과 **글자까지 같게**(브레인 판정). 벽 치수 자체가
# 기본값인 관 지점 기슭막이는 그 줄도 — 그림이 설계값처럼 보이면 더 위험함(2026-09-14).
wall_prefix = NOTE_DEFAULT_WALL.split("{")[0]
lines = [default_note] + [
str(n) for n in sheet.get("notes") or [] if str(n).startswith(wall_prefix)
]
for index, line in enumerate(line for line in lines if line):
shapes.append(_text(line, (0.0, -0.80 - base_d - index * 0.18), "left"))
return shapes
@@ -51,6 +51,7 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import ( # noqa: F401
Component,
StructureQuantity,
_num,
is_unconfirmed,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Masonry import ( # noqa: F401 — 다시 내보냄
boulder_masonry,
@@ -198,24 +199,26 @@ def wing_wall_double_count(options: dict[str, Any]) -> str | None:
return WING_WALL_DOUBLE_COUNT if options.get("inlet_basin_form") else None
def child_row(structure: dict[str, Any], type_id: str, label: str, suffix: str = "") -> dict:
"""딸린 줄 한 벌 — 이름에 「어디에 딸렸나」가 남음(`expand` 가 부모 이름 · 꼬리로 붙임)."""
return {
**structure,
"structure_id": f"{structure.get('structure_id')}-{suffix or type_id}",
"type_id": type_id,
"attachment_of": structure.get("structure_id"),
"attachment_parent_type": structure.get("type_id"),
"attachment_label": label,
}
def attachments_of(structure: dict[str, Any]) -> list[dict[str, Any]]:
"""구조물에 딸린 **별도 줄**을 만든다. 제원은 원본을 그대로 물려준다(치수 두 벌 금지)."""
rows: list[dict[str, Any]] = []
options = structure.get("options") or {}
for type_id, gate_key, label in ATTACHMENTS.get(str(structure.get("type_id") or ""), ()):
if not options.get(gate_key):
continue # 그 부속이 없는 배치다 — 빈 줄을 만들지 않는다
rows.append(
{
**structure,
"structure_id": f"{structure.get('structure_id')}-{type_id}",
"type_id": type_id,
"attachment_of": structure.get("structure_id"),
"attachment_parent_type": structure.get("type_id"),
"attachment_label": label,
}
)
return rows
return [
child_row(structure, type_id, label)
for type_id, gate_key, label in ATTACHMENTS.get(str(structure.get("type_id") or ""), ())
if options.get(gate_key) # 그 부속이 없는 배치다 — 빈 줄을 만들지 않는다
]
def _observed_components(
@@ -288,6 +291,10 @@ def expand(
end_m=end if structure.get("end_m") is not None else None,
options=dict(options),
)
# 줄을 만든 쪽이 붙인 사유(관 지점 시설의 기본값 · 못 가른 자리) — `withheld` 면 값을 안 세움.
result.notes.extend(str(note) for note in structure.get("notes") or [])
if structure.get("withheld"):
return result
withheld = EXPANDER_WITHHELD.get(type_id)
if withheld:
result.notes.append(f"전개식 미확보 — {withheld}")
@@ -522,6 +529,7 @@ def build_table(
if rubble is not None:
quantity.components.append(rubble)
_append_section_trench(quantity, item, observed, rubble_base_thickness_m)
quantity.unconfirmed = str(item.get("unconfirmed") or "")
quantities.append(quantity)
if use_templates:
# 늦게 부름 — 양식 모듈이 이 모듈을 부르므로 맨 위에서 부르면 맞물림.
@@ -538,7 +546,9 @@ def build_table(
violations = verify_no_mix_components(quantities)
totals: dict[str, dict[str, Any]] = {}
for item in quantities:
# 합은 **금액에 드는 구조물만** — 기본값으로 선 구조물(`unconfirmed`)은 줄에만 보임.
priced = [item for item in quantities if not item.unconfirmed]
for item in priced:
for component in item.components:
key = f"{component.name}|{component.spec}|{component.unit}"
entry = totals.setdefault(
@@ -575,6 +585,8 @@ def build_table(
"trench_depth_m": item.trench_depth_m,
# 양식 있음/없음 — 화면이 가림(비면 지금 전개).
"library_item": item.library_item,
# 기본값으로 선 까닭 — 차 있으면 금액·자재·토공·운반 합에서 빠짐(`is_unconfirmed`).
"unconfirmed": item.unconfirmed,
"notes": item.notes,
"components": [
{
@@ -613,7 +625,7 @@ def build_table(
COLLECTED_STONE_KEY: round(
sum(
component.amount
for item in quantities
for item in priced
for component in item.components
if component.name == "채집석"
),
@@ -108,6 +108,14 @@ class StructureQuantity:
trench_depth_m: float | None = None
#: 양식으로 성분을 세웠으면 그 양식 이름(PLAN 3장 ④-2). 비면 지금 전개 값.
library_item: str = ""
#: 치수가 정본에 없어 **등록부 기본값으로 선** 까닭(2026-09-14 브레인 판정) — 차 있으면 줄·표·그림은
#: 서되 **금액·자재·토공·운반 합에는 안 듦**(`is_unconfirmed`). 설계자가 치수를 적으면 비고 금액이 섬.
unconfirmed: str = ""
def is_unconfirmed(structure: dict[str, Any]) -> bool:
"""금액 합에서 뺄 구조물인가 — 합을 내는 자리마다 이 한 벌로 가림."""
return bool(structure.get("unconfirmed"))
def _num(value: Any, fallback: float = 0.0) -> float:
@@ -519,7 +519,9 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
if length_m <= 0:
return [], ["연장이 없어 전개하지 않음"]
from B08_Quantity.B08_Quantity_Wording import option_missing
return [], [option_missing("length_m", "open_ditch")]
spec = str(options.get("ditch_spec") or "콘크리트 개거 150×200").strip()
table = OPEN_DITCH_FORMS.get(spec)
if table is None:
+22 -1
View File
@@ -58,7 +58,17 @@ router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
def _collect_structures(
project_root: str,
) -> tuple[list[dict[str, Any]], dict[str, str], list[str]]:
"""전개 대상 구조물·타입명·건너뛴 사유를 함께 낸다."""
"""전개 대상 구조물·타입명·건너뛴 사유를 함께 낸다.
정본 둘을 읽음(A1, 2026-09-14) `structures.json` + `pipe_points.json`(계곡 통과 시설).
앞서 뒤엣것을 읽어 집수정·날개벽· 유입/유출 기슭막이·독립 기슭막이·물넘이포장이
원단위·인계·내역에 줄도 섰음.
지점 종류(`managed_by`) `structures.json` 저장분으로 남아 있어도
지점 정본이 주인이라 세지 않음(구조물 집계표와 같은 규칙).
"""
from B08_Quantity.B08_Quantity_Engine_Pipe import facility_structures
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
_revision, items = load_structures(project_root)
types = structure_type_map()
targets: list[dict[str, Any]] = []
@@ -72,6 +82,11 @@ def _collect_structures(
skipped.append(f"{type_id}: 레지스트리에 없는 타입")
continue
names[type_id] = definition.name
if definition.managed_by:
skipped.append(
f"{definition.name}: 관 지점 정본이 주인 — 구조물 목록 옛 저장분은 안 셈"
)
continue
if definition.design_owner:
skipped.append(
f"{definition.name}: {definition.design_owner} 가 이미 셈 — 중복 계상 방지"
@@ -86,6 +101,12 @@ def _collect_structures(
# 여기서 빼지 않으면 **같은 시설이 두 줄로** 나간다.
continue
targets.append(payload)
points = read_pipe_points_file(pipe_points_path_in(Path(project_root)))
for row in facility_structures([point.as_dict() for point in points]):
for type_id in (row["type_id"], row.get("attachment_parent_type")):
if type_id in types:
names[type_id] = types[type_id].name
targets.append(row)
return targets, names, sorted(set(skipped))
@@ -259,6 +259,13 @@ async def put_structure_sheet_spec(
try:
revision, stored = await asyncio.to_thread(load_structures, project_root)
updated, changed = apply_spec(stored, member_ids, spec)
outside = member_ids - {str(item.structure_id) for item in stored}
if outside:
# 관 지점 시설(A1)은 `pipe_points.json` 이 정본 — 조용히 안 먹히지 않게 알림.
notes.append(
f"관 지점 시설 {len(outside)}개소(배수관 기슭막이 등)는 이 화면에서 제원을 못 적음"
" — B05 배수 시설·구조물 집계표에서 고칠 것"
)
new_revision = await asyncio.to_thread(
save_structures, project_root, updated, base_revision=payload.base_revision
)
+3
View File
@@ -28,6 +28,7 @@ TYPE_LABELS = {
"pipe": "배수관",
"pipe_inlet_basin": "배수관 유입부 집수정",
"ford_pavement": "물넘이포장",
"open_ditch": "개거(겉도랑)",
"ford_bridge": "세월교",
"box_culvert": "BOX암거",
# 사방 시설 — 레지스트리 이름 그대로 옮긴 것(2026-09-08 확인). 지어낸 이름 아님.
@@ -44,6 +45,8 @@ OPTION_LABELS = {
"form": ("옹벽 형식", "구조물 상세 입력"),
"height_m": ("높이(m)", "구조물 배치"),
"length_m": ("연장(m)", "구조물 배치"),
# 물넘이포장(관 지점 시설) — B05 시설 칸 · 구조물 집계표에서 적음(2026-09-14 A3).
"thickness_cm": ("포장 두께(㎝)", "구조물 배치 물넘이포장 칸 · 구조물 집계표"),
# 2026-09-09 칸이 생겼음(`ebdf2988`) — 「아직 칸이 없음」이 거짓이 되어 고침.
# ⚠ 비워 두면 품셈 표준경사표로 자동 판정된다(확정 ⑨).
"face_slope_ratio": ("전면 기울기", "구조물 상세 입력 — 비우면 품셈 표준경사로 자동"),
@@ -64,8 +64,12 @@ SUPPLY_OWNER = "owner_supplied"
#: 그것을 「우리가 만들어야 하는 것」에 얹으면 **결국 이중계상으로 간다**(㉠~㉦ 규칙).
_NOT_OUR_ROW = "not_our_row"
#: B08 `BLOCKED_UNCONFIRMED` 와 같은 글 — 치수 없이 기본값으로 선 줄.
BLOCKED_UNCONFIRMED = "unconfirmed"
_BLOCKED_LABELS = {
_NOT_OUR_ROW: "여기서 세지 않는 줄",
BLOCKED_UNCONFIRMED: "미확정 — 금액에 안 들어감",
"input_missing": "입력이 필요합니다",
"unit_data_missing": "원단위가 없습니다(우리가 만들 것)",
"formula_missing": "전개식이 없습니다(우리가 만들 것)",
@@ -123,6 +127,12 @@ class HandoffWorkItem:
def display_name(self) -> str:
return f"{self.name} {self.spec}".strip()
@property
def unconfirmed(self) -> bool:
"""B08 이 치수 없이 기본값으로 세운 줄(2026-09-14 브레인 판정) — 내역 제자리에
** 금액 + 빨간 테두리** 서고 합계에 · 머리에 미확정 N건 금액에 들어감."""
return self.blocked_kind == BLOCKED_UNCONFIRMED
@dataclass(frozen=True)
class HandoffMaterial:
@@ -262,6 +272,8 @@ class BillResult:
material_sheet: Any = None
#: 수동 단가로 선 자리 — 내역서 끝 「미확정 N건」(PLAN 확정 ⑦). 줄마다 `{name, count}`.
unconfirmed: list[dict[str, Any]] = field(default_factory=list)
#: 치수 없이 기본값으로 선 구조물 줄 — 제자리에 빈 금액으로 서고 **합계에 안 듦**(`{name, reason}`).
unpriced: list[dict[str, Any]] = field(default_factory=list)
@property
def direct_material_krw(self) -> Decimal:
@@ -435,7 +447,8 @@ def build_bill(
composites: list[HandoffWorkItem] = []
templated: list[HandoffWorkItem] = []
for item in work_items:
if not item.in_bill:
if not item.in_bill and not (item.unconfirmed and item.work_item_code in index):
# (미확정 줄은 제자리 나무로 감 — `_leaf_row` 가 금액 없이 세우고 `unpriced` 에 셈)
# ⚠ 코드 유무보다 **먼저** 가른다. 보정량계는 공종코드가 없어서가 아니라
# **검산용 줄이라서** 금액이 없는 것이다 — `missing` 으로 새면 「단가를 구해야 할
# 줄」로 잘못 읽힌다.
@@ -546,12 +559,18 @@ def build_bill(
if item.origin == "structure"
else "공종을 못 골랐습니다 — B08 인계에 공종코드가 없습니다."
)
# ⚠ B08 이 막힘 까닭을 실어 보냈으면 **그 문구를 그대로** — 「코드가 없다」로 덮으면
# 입력하면 풀리는 자리(암 갈래 구성비·시공법)가 매핑 구멍으로 읽힘(2026-09-14 흙깎기 암).
if item.blocked_reason:
kind = item.blocked_kind or _NOT_OUR_ROW
reason = f"{_BLOCKED_LABELS.get(kind, '막힘')}{item.blocked_reason}"
result.missing.append(
{
"name": item.display_name,
"unit": item.unit,
"quantity": str(item.quantity),
"reason": reason,
**({"blocked_kind": item.blocked_kind} if item.blocked_kind else {}),
}
)
@@ -609,7 +628,15 @@ def build_bill(
),
_ZERO,
)
haul_total = sum((item.quantity for item in work_items if item.haul_equipment), _ZERO)
# 「덤프 적재」 짝 줄은 운반이 아니라 싣기 — 같은 흙을 운반량으로 두 번 세지 않게 뺌.
haul_total = sum(
(
item.quantity
for item in work_items
if item.haul_equipment and item.haul_equipment != "dump_loading"
),
_ZERO,
)
if cut_total > 0 and haul_total > 0:
check_haul_volume_within_cut(
haul_volume_total_m3=haul_total,
@@ -669,6 +696,8 @@ def bill_summary(result: BillResult) -> dict[str, Any]:
"missing": result.missing,
"unconfirmed": result.unconfirmed,
"unconfirmed_count": sum(int(entry["count"]) for entry in result.unconfirmed),
"unpriced": result.unpriced,
"unpriced_count": len(result.unpriced),
"body_total_krw": str(result.body_total_krw),
"direct_material_krw": str(result.direct_material_krw),
"direct_labor_krw": str(result.direct_labor_krw),
@@ -22,9 +22,20 @@ from B09_Estimation.B09_Estimation_BillOfQuantities import (
_MasterNode,
_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_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:
@@ -267,6 +278,10 @@ def _leaf_row(
# 검산용 줄 — 수량은 보이되 **단가를 안 붙인다**(PLAN 8-7 ㉡ 와 같은 성격).
row.quantity = item.quantity
row.add_note("", item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다.")
if item.unconfirmed:
# 치수 없이 기본값으로 선 줄 — 빨간 테두리 + 머리 「미확정 N건 — 금액에 안 들어감」.
row.unconfirmed = 1
result.unpriced.append({"name": item.display_name, "reason": item.blocked_reason})
result.excluded.append(row)
return row
@@ -299,6 +314,41 @@ def _leaf_row(
return row
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:
# B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다.
# 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다.
@@ -132,6 +132,32 @@ MASHED_MACHINE_FIXES: dict[str, tuple[str, str]] = {
#: (0230) 표의 「시간당 계」 — 규격이 달라도 같은 값이다(원문 여섯 줄 모두 6,601).
MASHED_LOSS_COEFFICIENT = Decimal("0.0006601")
#: ⚠ **같은 파싱 병의 나머지** (2026-09-14 · 축 C 1장 ① 브레인 차례).
#: 원천이 쪽 끝 기종의 이름 칸에 **다음 쪽 머리글 + 다음 분류 이름**을 붙여 넣어 ① 그 기종은
#: 이름에 표 머리글이 붙고 규격이 비고 ② 다음 분류 첫 기종들은 **이름이 빔**. 이름으로 찾아지지
#: 않고 화면에도 빈 이름으로 선다. 이름·규격은 **건설공사 표준품셈 제8장 표**(`pum_const_2026.json`
#: 기종 목록 줄 · 붙은 꼬리 「(분류번호) 이름」)에서 읽음 — 취득가·손료계수는 원천 그대로.
#: ⚠ 원천이 바로 실으면 이 표는 지운다(위 표와 같은 약속).
GLUED_MACHINE_FIXES: dict[str, tuple[str, str]] = {
"5220-0015": ("소형브레이커(전기식)", "1.5㎾"),
"6532-0220": ("진동파일 해머(유압식)", "162㎾"),
"7101-0450": ("고성능 착정기", "335.70㎾"),
"7120-0746": ("버킷식준설기", "7.46kW"),
"7995-0050": ("배관파이프", "ø50-2.6m"),
}
#: 이름이 빈 분류 — 분류번호(코드 앞 넷) → 원문 이름. 규격은 원천 값 그대로.
EMPTY_NAME_BY_GROUP: dict[str, str] = {
"0240": "유압식 진동콤팩터(굴착기 부착용)",
"3611": "콘크리트 피니셔(중앙분리대용)",
"5330": "드릴웨곤",
"6540": "워터젯트",
"6802": "파일천공전용장비",
"7202": "자동세륜기(롤 타입)",
"7930": "모터",
"8201": "3D GNSS 머신 가이던스(굴착기용)",
"9070": "이우선(비자항)",
}
class MachineCostError(LookupError):
"""기계경비를 세울 수 없는 경우. 0 으로 때우지 않고 멈춘다."""
@@ -205,6 +231,11 @@ def load_machine_catalog(file_name: str = "mach_base_2026.json") -> MachineCatal
# 뭉개진 줄 — 원문으로 이름·규격을 되살리고 손료계수를 채운다.
row = {**row, "machine_name": fixed[0], "specification": fixed[1]}
coefficient = {**coefficient, "loss_coefficient_per_hour": MASHED_LOSS_COEFFICIENT}
elif code in GLUED_MACHINE_FIXES:
name, spec = GLUED_MACHINE_FIXES[code]
row = {**row, "machine_name": name, "specification": spec}
elif not str(row.get("machine_name") or "").strip() and code[:4] in EMPTY_NAME_BY_GROUP:
row = {**row, "machine_name": EMPTY_NAME_BY_GROUP[code[:4]]}
catalog.machines[code] = MachineSpec(
machine_code=code,
name=row["machine_name"],
@@ -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
)
@@ -269,34 +269,75 @@ def match_spec_column_table(
if len(specs) < 2 or not all(_SPEC_HEAD.match(spec) for spec in specs):
return False
from B09_Estimation.B09_Estimation_ResourceAxis_Join import resolve_family, unmatched_reason
unit = table.get("basis_unit") or ""
work_item_code = str(node.get("work_item_code", ""))
table_id = str(table.get("pum_table_id", ""))
form = str(table.get("pum_form", ""))
made = 0
previous_note = ""
def unmatched(cell: str, reason: str) -> None:
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code, pum_table_id=table_id, cell=cell, reason=reason
)
)
for index, row in enumerate(rows[1:], start=1):
cells = [_clean(cell) for cell in row]
if not cells:
if not cells or not cells[0]:
continue
name, spec_text = split_name_and_spec(cells[0])
entry = catalog.resolve(name, spec_text) or catalog.resolve(name, "")
if entry is None:
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=cells[0],
reason="카탈로그에 없는 이름 (규격이 열로 선 표) — 0 으로 때우지 않습니다.",
)
# 줄 모양 — | 구분 | 규격 | 단위 | 관경별 값 … | 비고 | (2026-09-14 · 원문 12-11 표 머리)
values = [
value for value in (_cell_amount(cell) for cell in cells[1:]) if value is not None
]
note = cells[-1] if len(cells) > 1 and _cell_amount(cells[-1]) is None else ""
note = previous_note if note == "" else note
spec_cell = cells[1] if len(cells) > 1 and _cell_amount(cells[1]) is None else ""
if not values and not note and previous_note.replace(" ", "") == "설계수량":
note = previous_note # 원문 병합 칸 — 바로 위 줄 비고를 따름(흄관 거푸집)
previous_note = note
tight_note = note.replace(" ", "")
# ⚠ 원문 비고가 「이 일위대가 밖」인 줄 — 못 찾은 게 아니라 안 넣는 것 · 까닭을 남김.
if tight_note in ("별산", "별도계산"):
unmatched(
cells[0], f"{note}」 — 원문 비고대로 이 일위대가 밖에서 따로 셈 · 여기 안 넣음"
)
continue
values = [_cell_amount(cell) for cell in cells[1:]]
values = [value for value in values if value is not None]
if len(values) < len(specs):
# 값이 빈 규격이 있다 — 「별산」 줄이거나 표가 덜 찼다. 짐작해 채우지 않는다.
if tight_note == "설계수량":
unmatched(cells[0], "「설계수량」 — 원문 비고대로 설계 물량으로 따로 셈 · 여기 안 넣음")
continue
for spec_name, amount in zip(specs, values[-len(specs) :]):
if len(values) < len(specs):
why = f"{note}」 — " if note else ""
unmatched(cells[0], f"{why}관경별 값이 비어 짐작해 채우지 않음")
continue
name, inline_spec = split_name_and_spec(cells[0])
row_specs = [text for text in (spec_cell, inline_spec) if text]
entries: list[CatalogEntry] = []
for spec_name in specs:
entry = (
next(
(
found
for text in (*row_specs, spec_name)
if (found := catalog.resolve(name, text)) is not None
),
None,
)
or catalog.resolve(name, "")
or resolve_family(catalog, cells[0], cells)
)
if entry is None:
break
entries.append(entry)
if len(entries) < len(specs):
origin = f" · 원문 규격 {spec_cell}" if spec_cell else ""
unmatched(cells[0], f"{unmatched_reason(catalog, name)}{origin} (규격이 열로 선 표)")
continue
for spec_name, amount, entry in zip(specs, values[-len(specs) :], entries):
result.rows.append(
ResourceRow(
work_item_code=work_item_code,
@@ -168,7 +168,8 @@ def spec_candidates(cells: list[str]) -> list[str]:
if spec:
found.append(spec)
continue
token = _RE_SIZE_TOKEN.fullmatch(text.rstrip("㎥㎡톤tonm³"))
# 「40.64㎝」(절단기 12-11-2) — 카탈로그는 수만 적음(7620-0003 절단기 40.64).
token = _RE_SIZE_TOKEN.fullmatch(text.rstrip("㎥㎡톤tonm³㎝"))
if token:
found.append(token.group(0))
return found
+17 -6
View File
@@ -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_BillOfQuantities import bill_summary, build_bill
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_Statutory import STATUTORY_ITEMS
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
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 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())),
# 조종원 시간당 노임 자르는 자리 — 실무마다 다름(명세 7장 정정). 안 정하면 원 미만.
str(settings.get("operator_wage_digits") or ""),
tuple(sorted(set(dump_haul_m), key=Decimal)),
)
# 사용자가 고친 값(PLAN 12장 2차) — 없으면 기본 조립 그 벌 그대로.
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:
return JSONResponse(
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:
@@ -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
try:
# ⚠ **그 프로젝트가 고른 값**으로 조립한 단가표 — 구조물도 미리보기(`_build_for`)와 한 벌.
# 기본값(`cached_build()`)을 쓰면 계수를 고른 프로젝트에서 화면과 내역이 갈림(브레인).
build = await _build_for(project_id)
response = await get_handoff(project_id)
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:
logger.exception("B09 내역서 조회 실패(인계): project_id=%s", project_id)
return JSONResponse(
@@ -535,7 +546,7 @@ async def get_price_basis_detail(project_id: UUID, code: str) -> JSONResponse:
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:
logger.exception("B09 단가산출서 조회 실패: project_id=%s, code=%s", project_id, code)
return JSONResponse(
@@ -129,6 +129,8 @@ export interface BillDto {
detail_rows: number;
missing: MissingDto[];
unconfirmed_count: number;
/** 치수 없이 기본값으로 선 구조물 줄 — 제자리에 빈 금액으로 서고 합계에 안 듦. */
unpriced_count?: number;
notes: string[];
material_sheet: MaterialSheetDto | null;
};
@@ -202,6 +202,15 @@ function drawBill(ctx: B09TabContext, bill: BillDto, reload: () => void): void {
);
if (bill.summary.unconfirmed_count > 0)
bar.append(unconfirmedBadge(bill.summary.unconfirmed_count));
// 치수 없이 기본값으로 선 줄 — 빨간 테두리 줄로 서고 금액·합계엔 안 듦(브레인 판정 2026-09-14).
if (bill.summary.unpriced_count)
bar.append(
el(
"span",
"b09s-badge",
`${L("B09_Sheet_Unconfirmed")} ${bill.summary.unpriced_count}${L("B09_Sheet_Count")}${L("B09_Sheet_Unpriced")}`,
),
);
const rateToggle = el("button", "b09s-undo", L("B09_Sheet_Rate"));
rateToggle.type = "button";
rateToggle.addEventListener("click", () => {
@@ -617,6 +617,7 @@ def build_unit_prices(
transport_road: str | None = None,
labor_surcharge_choices: dict[str, str] | None = None,
operator_wage_digits: int = 0,
dump_haul_m: tuple[Decimal, ...] = (),
) -> UnitPriceBuild:
"""자원 축을 일위대가(`B`)로 조립한다.
@@ -771,6 +772,14 @@ def build_unit_prices(
from B09_Estimation.B09_Estimation_Transport import 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.incomplete_machines = _add_machine_layers(
build.book, machine_codes, fuel_region, operator_wage_digits
@@ -1022,6 +1031,8 @@ def build_unit_prices(
distance_km=transport_distance_km,
road_key=transport_road,
)
# 덤프 운반 — 운반거리(B08 유토곡선·사토장)마다 한 벌(산림품셈 10-12 「2. 운반」).
attach_dump_hauls(build, master, dump_haul_m)
# ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다
# (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다.
@@ -1161,6 +1172,7 @@ def cached_build(
transport_road: str = "",
labor_surcharge: tuple[tuple[str, str], ...] = (),
operator_wage_digits: str = "",
dump_haul_m: tuple[str, ...] = (),
) -> UnitPriceBuild:
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.
@@ -1188,6 +1200,7 @@ def cached_build(
transport_road=transport_road or None,
labor_surcharge_choices=dict(labor_surcharge),
operator_wage_digits=parse_operator_wage_digits(operator_wage_digits),
dump_haul_m=tuple(Decimal(value) for value in dump_haul_m),
)
@@ -259,6 +259,58 @@
"F0243"
]
}
},
{
"code": "AR-M-423cfb79",
"kind": "material",
"name": "고무링",
"spec": "∅800mm",
"unit": "개",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0346"
]
}
},
{
"code": "AR-M-18c0ce85",
"kind": "material",
"name": "고무링",
"spec": "∅1000mm",
"unit": "개",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0346"
]
}
},
{
"code": "AR-M-d9d5aa16",
"kind": "material",
"name": "고무링",
"spec": "∅1200mm",
"unit": "개",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0346"
]
}
},
{
"code": "AR-M-0cc44237",
"kind": "material",
"name": "지수활제",
"spec": "",
"unit": "g",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0346"
]
}
}
]
}
@@ -220,6 +220,21 @@
"work_item_code": "FP-12-15",
"master_name": "집수정"
},
{
"type_id": "revetment",
"work_item_code": null,
"class_from": "form",
"form_codes": {
"돌쌓기(찰)": "FP-13-04-05",
"돌쌓기(메)": "FP-13-04-02"
},
"class_note": "기슭막이는 **형태가 돌쌓기면 돌쌓기 식**(`_UnitQuantity_Revetment`) — 공종도 돌쌓기 찰·메와 같음(실무 정본 탭 「돌기슭막이(H=2.0m, 찰쌓기, 기초유)」). 그 밖 형태(콘크리트·돌망태·통나무·바자)는 전개식이 없어 사유로 막힘. 관 유입·유출부 기슭막이와 독립 기슭막이가 이 줄로 섬(A1, 2026-09-14).",
"secondary_axes": [
"stone_kind"
],
"billing_component": "돌쌓기",
"variant_axis": "back_len_cm"
},
{
"type_id": "ford_pavement",
"work_item_code": "FP-12-06",
@@ -414,7 +429,7 @@
"diameter_option_key": "pipe_diameter_mm",
"variant_axis": "pipe_diameter_mm",
"facility_rule": "⚠ `pipe_points.json` 은 **계곡 통과 시설 전부의 정본**이다(배관·BOX암거·물넘이·세월교·독립 기슭막이). `facility` 가 `pipe` 인 점만 배관이다 — `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다**(2026-09-08 랩탑 창).",
"revetment_note": "⚠ 유출·유입부 기슭막이는 **관 옵션(`inlet_revet_*`·`outlet_revet_*`)이 정본**이다. 레지스트리 `revetment` 타입이 `managed_by: pipe_points` 라 구조물 목록에서 빠졌으므로 **구조물 쪽으로 또 세지 않는다**(2026-08-28 이관).",
"revetment_note": "⚠ 유출·유입부 기슭막이는 **관 옵션(`inlet_revet_*`·`outlet_revet_*`)이 정본**이다. 레지스트리 `revetment` 타입이 `managed_by: pipe_points` 라 구조물 목록에서 빠졌다(2026-08-28 이관). ⭐ **관 줄에는 안 넣고 원단위 전개가 한 번 셈** — B08 이 관 지점을 읽어 「배수관 · 유입부/유출부 기슭막이」 줄로 세움(`Engine_Pipe.facility_structures`, A1 2026-09-14). 관 부설(품셈 12-11 m당: 관·기초콘크리트·거푸집)에 기슭막이 몫이 없어 겹치지 않음. 그전에는 **어느 쪽도 안 세고 있었음**.",
"not_ready": {
"흄관 밑수 두 벌": "`FP-12-11-02` 는 밑수가 「1 m」와 「1 개소」 두 벌이다(표가 둘). B09 가 `#갈래` 로 두 표를 각각 세우므로 B08 은 `variant_value` 로 어느 쪽인지 보내면 된다.",
"터파기·되메우기": "⚠ 관 부설과 터파기·되메우기가 각각 오면 **같은 굴착을 두 번 셀 수 있다**(B09 ㉡ 가드). 관 줄에는 지금 터파기를 붙이지 않는다."
@@ -0,0 +1,118 @@
"""B05 시설 [저장]이 폼이 모르는 칸을 안 지우는가 (2026-09-14 브레인 판정).
앞서 저장이 옵션을 통째로 갈아 끼워, 구조물 집계표·구조물도로 적은 ( 기슭막이 기초 )
B05 에서 시설을 저장하면 조용히 지워졌음 되돌릴 길이 없는 사고.
: `B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts` 실제 TS 옮겨 돌림.
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
MERGE_TS = ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility_Merge.ts"
FORM_TS = ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts"
RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { FORM_OPTION_KEYS, mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge.js";
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
const out = cases.map(([previous, next]) => mergeFacilityOptions(previous, next));
writeFileSync(process.argv[3], JSON.stringify({ out, keys: FORM_OPTION_KEYS }));
"""
PIPE_BEFORE = {
"facility": "pipe",
"options": {
"pipe_diameter_mm": 1000,
"inlet_type": "집수정",
"inlet_basin_form": "□형(기본형)",
"revet_foundation": "기초유", # 집계표로 적은 칸 — 폼에 없음
"inlet_basin_before_m": 1.5, # 횡단(B06)이 적는 칸 — 폼에 없음
},
}
# 폼이 다시 저장 — 유입구를 기슭막이로 바꿔 집수정 칸은 안 보냄
PIPE_FORM = {
"facility": "pipe",
"options": {
"pipe_diameter_mm": 800,
"inlet_type": "기슭막이",
"inlet_revet_form": "돌쌓기(찰)",
},
}
REVET_BEFORE = {"facility": "revetment", "options": {"side": "양쪽", "back_len_cm": "35"}}
TYPE_CHANGE = {"facility": "ford_pavement", "options": {"ford_width_m": 5}}
@pytest.fixture(scope="module")
def merged(tmp_path_factory) -> dict:
if not TSC.is_file():
pytest.skip("프론트엔드 의존성 미설치")
out = tmp_path_factory.mktemp("merge_js")
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(MERGE_TS),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
(out / "runner.mjs").write_text(RUNNER, encoding="utf-8")
cases = [
[PIPE_BEFORE, PIPE_FORM],
[REVET_BEFORE, {"facility": "revetment", "options": {"side": ""}}],
[PIPE_BEFORE, TYPE_CHANGE],
[None, PIPE_FORM],
]
(out / "cases.json").write_text(json.dumps(cases, ensure_ascii=False), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(out / "cases.json"), str(out / "result.json")],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads((out / "result.json").read_text(encoding="utf-8"))
def test_폼이_모르는_칸은_남고_폼이_아는_칸은_폼대로(merged) -> None:
options = merged["out"][0]["options"]
assert options["revet_foundation"] == "기초유" # 집계표 값이 살아남음
assert options["inlet_basin_before_m"] == 1.5
assert options["pipe_diameter_mm"] == 800 # 폼 값이 이김
assert "inlet_basin_form" not in options # 폼이 아는 칸을 폼이 비움 → 지움
assert merged["out"][1]["options"] == {"side": "", "back_len_cm": "35"}
def test_시설_종류를_바꾸면_통째로_갈아_끼움(merged) -> None:
assert merged["out"][2] == TYPE_CHANGE
assert merged["out"][3] == PIPE_FORM
def test_폼이_적는_칸이_모두_목록에_있다(merged) -> None:
"""폼에 칸을 늘리고 목록에 안 넣으면 여기서 잡힘."""
source = FORM_TS.read_text(encoding="utf-8")
body = source[source.index("readOptions() {") :]
branches = re.split(r'current === "(\w+)"', body)[1:]
assert branches[::2] == ["pipe", "box_culvert", "ford_pavement", "ford_bridge", "revetment"]
for facility, block in zip(branches[::2], branches[1::2]):
written = set(re.findall(r'putNumber\(options, "(\w+)"', block))
written |= set(re.findall(r"options\.(\w+) =", block))
missing = written - set(merged["keys"][facility])
assert not missing, f"{facility}: 폼이 적는데 목록에 없음 {missing}"
@@ -0,0 +1,60 @@
"""등록부 칸 채우기 A2·A3 (2026-09-14) — 표가 읽는 칸이 등록부에 없어 영영 안 서던 자리.
A2 개거( 배치) 연장 · A3 물넘이포장( 지점 시설) 두께·노폭 방향 길이 .
저장본( 없음) 깨지고 **사유가 보임** 값이 없으면 표가 선다 아니라 사유를 보인다.
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile.B05_Profile_Structures_Repository import _validate_types # noqa: E402
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map # noqa: E402
from B08_Quantity.B08_Quantity_Engine_Pipe import facility_structures # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
def _ditch(**options) -> dict:
return build_table(
[{"structure_id": "d", "type_id": "open_ditch", "chainage_m": 10.0, "options": options}]
)["structures"][0]
def test_개거_연장_칸이_등록부에_서고_구조물_목록에_저장된다() -> None:
keys = {option.key for option in structure_type_map()["open_ditch"].options}
assert "length_m" in keys
item = StructureInstance(
type_id="open_ditch", placement="point", chainage_m=10.0, options={"length_m": 12}
)
_validate_types([item]) # 등록부에 없는 칸이면 여기서 거절됨
def test_개거_연장이_있으면_표가_서고_없으면_어디서_적는지_사유() -> None:
assert _ditch(length_m=12)["components"]
old = _ditch() # 옛 저장본 — 칸이 없음
assert not old["components"]
assert old["notes"][0].startswith("개거(겉도랑) 연장(m)이(가) 아직 입력되지 않았습니다")
def _ford(**options) -> dict:
point = {"facility": "ford_pavement", "chainage_m": 30.0, "options": options}
return build_table(facility_structures([point]))["structures"][0]
def test_물넘이포장_두께_길이_칸이_있으면_면적으로_선다() -> None:
keys = {option.key: option for option in structure_type_map()["ford_pavement"].options}
assert keys["thickness_cm"].phase == "detail" and keys["length_m"].phase == "detail"
row = _ford(ford_width_m=5, thickness_cm=20, length_m=8)
assert row["components"] and (row["billing_unit"], row["billing_quantity"]) == ("", 40.0)
def test_물넘이포장_칸이_비면_무엇을_적을지_사유() -> None:
old = _ford(ford_width_m=5)
assert not old["components"] and "포장 두께(㎝)" in old["notes"][0]
no_length = _ford(ford_width_m=5, thickness_cm=20)
assert not no_length["components"] and "포장 길이" in no_length["notes"][0]
@@ -0,0 +1,117 @@
"""계곡 통과 시설(`pipe_points.json`)이 원단위 전개·인계에 선다 (A1, 2026-09-14).
앞서 전개가 `structures.json` 읽어 유입·유출부 기슭막이·집수정·독립 기슭막이가
원단위·내역에 줄도 섰음. (품셈 12-11 m당)에는 기슭막이 몫이 없어 ** 번만** .
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402
from B08_Quantity import B08_Quantity_Router_Material as material # noqa: E402
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_Pipe import build_rows, facility_structures # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
NAMES = {"pipe": "배수관", "revetment": "기슭막이", "ford_bridge": "세월교"}
def _table(points: list[dict]) -> dict:
return build_table(facility_structures(points), NAMES)
def test_관_하나에_기슭막이_둘이_기본값_사유와_함께_서고_관_줄에는_없다() -> None:
point = {"chainage_m": 100.0, "options": {"pipe_diameter_mm": 1000}}
table = _table([point])
names = [s["name"] for s in table["structures"]]
assert names == ["배수관 · 유입부 기슭막이", "배수관 · 유출부 기슭막이"]
inlet, outlet = table["structures"]
assert inlet["options"]["form"] == "돌쌓기(찰)" and outlet["options"]["form"] == "돌쌓기(메)"
assert all(s["components"] and s["length_m"] == 10 for s in table["structures"])
assert "등록부 기본값으로 섰음" in inlet["notes"][0]
# 관 줄은 관 연장만 — 기슭막이가 거기 섞이지 않음(두 번 안 셈).
pipe = build_rows([point], [{"chainage_m": 100.0, "design": {"pipe_length_m": 8}}])
assert [row["quantity"] for row in pipe["rows"]] == [8.0]
work = {row["name"]: row for row in build_handoff(unit_quantity_table=table)["work_items"]}
assert work["배수관 · 유입부 기슭막이"]["work_item_code"] == "FP-13-04-05"
assert work["배수관 · 유출부 기슭막이"]["work_item_code"] == "FP-13-04-02"
assert work["배수관 · 유입부 기슭막이"]["unit"] == ""
def test_기본값으로_선_벽은_줄만_서고_금액_합에는_안_든다() -> None:
"""브레인 판정(2026-09-14) — 줄은 서야 채울 자리가 보이고, 금액은 실제 값이 있을 때만."""
point = {"chainage_m": 100.0, "options": {"pipe_diameter_mm": 1000}}
table = _table([point])
assert all(s["unconfirmed"] and s["components"] for s in table["structures"])
assert table["totals"] == [] # 원단위 합(자재·채집석 밑수)에도 안 듦
handoff = build_handoff(unit_quantity_table=table)
rows = handoff["work_items"]
walls = [r for r in rows if "기슭막이" in r["name"]]
assert len(walls) == 2 and all(
not r["in_bill"] and r["blocked_kind"] == "unconfirmed" for r in walls
)
assert walls[0]["quantity"] > 0 # 수량은 보임
# 터파기·되메우기·기초잡석·버림 타설이 금액 줄로 번지지 않음
assert not [r for r in rows if r["in_bill"] and r["quantity"] > 0]
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill
bill = build_bill(handoff)
placed = [r for r in bill.rows if not r.is_group and "기슭막이" in r.name]
assert len(placed) == 2 and all(r.unconfirmed == 1 and r.amount_krw is None for r in placed)
summary = bill_summary(bill)
assert summary["unpriced_count"] == 2 and summary["body_total_krw"] == "0"
# 치수를 다 적으면 미확정이 풀림
filled = {
f"{side}_revet_{key}": value
for side in ("inlet", "outlet")
for key, value in (("form", "돌쌓기(메)"), ("height_m", 1.5), ("length_m", 6))
}
confirmed = _table([{**point, "options": {**point["options"], **filled}}])
assert not any(s["unconfirmed"] for s in confirmed["structures"])
def test_유입구가_집수정이면_집수정_줄이_사유로_서고_유입_기슭막이는_없다() -> None:
table = _table([{"chainage_m": 5.0, "options": {"inlet_type": "집수정"}}])
basin, outlet = table["structures"]
assert basin["type_id"] == "pipe_inlet_basin" and not basin["components"] and basin["notes"]
assert outlet["name"] == "배수관 · 유출부 기슭막이"
def test_유입구가_기슭막이인데_집수정_형식이_남아_있으면_집수정은_안_세고_알린다() -> None:
options = {"inlet_type": "기슭막이", "inlet_basin_form": "□형(기본형)"}
inlet = _table([{"chainage_m": 5.0, "options": options}])["structures"][0]
assert inlet["type_id"] == "revetment" and "집수정은 안 셈" in inlet["notes"][0]
def test_독립_기슭막이_한쪽이면_두_칸이_같을_때만_세고_다르면_사유() -> None:
same = {"facility": "revetment", "chainage_m": 50.0, "options": {"side": ""}}
rows = _table([same])["structures"]
assert [r["name"] for r in rows] == ["기슭막이 · 좌 벽"] and rows[0]["components"]
differ = {**same, "options": {"side": "", "inlet_revet_height_m": 3.0}}
row = _table([differ])["structures"][0]
assert not row["components"] and "서버가 못 가림" in row["notes"][-1]
def test_산출식_없는_세월교는_사유로_서고_터파기_빠짐_줄을_안_만든다() -> None:
table = _table([{"facility": "ford_bridge", "chainage_m": 30.0, "options": {}}])
assert "산출식이 아직 없습니다" in table["structures"][0]["notes"][0]
rows = build_handoff(unit_quantity_table=table)["work_items"]
assert not any("터파기가 안 선 구조물" in str(row.get("spec_detail")) for row in rows)
def test_구조물_목록의_관_지점_종류_옛_저장분은_안_셈(monkeypatch, tmp_path) -> None:
old = StructureInstance(
structure_id="old", type_id="revetment", placement="point", chainage_m=10.0
)
monkeypatch.setattr(material, "load_structures", lambda root: (1, [old]))
targets, _names, skipped = material._collect_structures(str(tmp_path))
assert targets == [] and any("관 지점 정본이 주인" in note for note in skipped)
+24 -3
View File
@@ -188,6 +188,11 @@ def test_무대는_넘기되_내역에는_안_섬() -> None:
assert dump["in_bill"] is True
assert dump["work_item_code"] == "FP-10-12"
assert dump["haul_distance_m"] == pytest.approx(1200.0)
# 2026-09-14 — 덤프 운반마다 「덤프 적재」 짝 줄(10-12 「1. 적재」) · 거리 없음 · 수량 같음
loading = (handoff, "덤프 적재")
assert loading["haul_equipment"] == "dump_loading" and loading["haul_distance_m"] is None
assert loading["quantity"] == dump["quantity"] and loading["variant_value"] == "토사"
assert not any(r["name"] == "덤프 적재" and r["spec"] != "토사" for r in handoff["work_items"])
def test_합계_줄은_내역에_안_섬() -> None:
@@ -367,6 +372,20 @@ def test_시공법을_안_정하면_찍지_않고_드러냄() -> None:
assert any("시공법" in item for item in handoff["unmatched_work_items"])
def test_갈래로_안_나뉜_암은_구성비가_빠졌다고_말함() -> None:
"""2026-09-14 — 한 줄 「암」은 시공법만 골라서는 안 풀림. B09 내역도 이 까닭을 그대로 실음."""
handoff = build_handoff(summary_table=집계표(집계줄("흙깎기", "", 100.0)))
row = handoff["work_items"][0]
assert row["work_item_code"] is None and row["blocked_kind"] == "input_missing"
assert "암 갈래 구성비" in row["blocked_reason"]
assert handoff["missing_method_classes"] == [] # 시공법 안내에 「암」을 안 띄움
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
bill = build_bill(handoff)
assert any("입력이 필요합니다 — 암 갈래 구성비" in m["reason"] for m in bill.missing)
def test_토사는_시공법이_필요없음() -> None:
handoff = build_handoff(summary_table=집계표(집계줄("흙깎기", "토사", 100.0)))
assert handoff["work_items"][0]["work_item_code"] == "FP-09-03-02"
@@ -586,9 +605,11 @@ def 운반표() -> dict:
def test_운반계획이_있으면_네_줄이_실림() -> None:
handoff = build_handoff(haul_table=운반표())
haul_rows = [row for row in handoff["work_items"] if row["origin"] == "haul"]
assert len(haul_rows) == 4
# 운반 넷 + 덤프 운반 둘의 적재 짝 줄 둘(2026-09-14 · 10-12 「1. 적재」 — 거리 없음)
moves = [row for row in haul_rows if row["haul_equipment"] != "dump_loading"]
assert len(moves) == 4 and len(haul_rows) == 6
assert all(row["haul_equipment"] for row in haul_rows)
assert all(row["haul_distance_m"] for row in haul_rows)
assert all(row["haul_distance_m"] for row in moves)
def test_무대가_함께_와야_운반_검산이_걸림() -> None:
@@ -598,7 +619,7 @@ def test_무대가_함께_와야_운반_검산이_걸림() -> None:
assert free["in_bill"] is False
assert free["quantity"] == pytest.approx(871.0)
assert handoff["excluded_row_count"] == 1
assert handoff["bill_row_count"] == 3
assert handoff["bill_row_count"] == 5 # 도자 · 덤프 운반 둘 · 덤프 적재 짝 줄 둘
def test_운반계획이_없으면_줄이_없음() -> None:
@@ -131,10 +131,18 @@ def test_막힌_까닭이_세_갈래_중_하나일것() -> None:
from B08_Quantity.B08_Quantity_Engine_Handoff import (
BLOCKED_FORMULA_MISSING,
BLOCKED_INPUT_MISSING,
BLOCKED_UNCONFIRMED,
BLOCKED_UNIT_DATA_MISSING,
)
allowed = {None, BLOCKED_INPUT_MISSING, BLOCKED_UNIT_DATA_MISSING, BLOCKED_FORMULA_MISSING}
# ⭐ 넷째 갈래(2026-09-14 브레인 판정) — 치수 없이 기본값으로 선 줄: 수량은 보이되 금액 밖.
allowed = {
None,
BLOCKED_INPUT_MISSING,
BLOCKED_UNIT_DATA_MISSING,
BLOCKED_FORMULA_MISSING,
BLOCKED_UNCONFIRMED,
}
for row in _rows()["work_items"]:
assert row["blocked_kind"] in allowed
+126
View File
@@ -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
@@ -0,0 +1,60 @@
"""기계 카탈로그 이름 — 원천 파싱 병(쪽 끝 이름에 다음 쪽 머리글이 붙고 다음 분류 이름이 빔) 되살림.
2026-09-14 C 1 (브레인 차례) · 원문 = 건설공사 표준품셈 제8장 기종 목록 .
머리글( ·(10-7)) 붙은 이름이 하나도 없음
이름이 하나도 없음
되살린 이름·규격이 원문 차례와 같음 · 취득가는 원천 그대로
"""
from __future__ import annotations
import json
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_MachineCost import load_machine_catalog # noqa: E402
CATALOG = load_machine_catalog()
def test_표_머리글이_붙은_이름이_없다() -> None:
glued = [
m.machine_code
for m in CATALOG.machines.values()
if "시 간 당" in m.name or "(10-7)" in m.name
]
assert glued == []
def test_빈_이름이_없다() -> None:
assert [m.machine_code for m in CATALOG.machines.values() if not m.name.strip()] == []
def test_되살린_이름과_규격() -> None:
expected = {
"5220-0015": ("소형브레이커(전기식)", "1.5㎾"),
"7101-0450": ("고성능 착정기", "335.70㎾"),
"0240-0007": ("유압식 진동콤팩터(굴착기 부착용)", "0.7"),
"3611-0142": ("콘크리트 피니셔(중앙분리대용)", "105.9"),
"6802-0100": ("파일천공전용장비", "100"),
"9070-0020": ("이우선(비자항)", ""),
}
for code, (name, spec) in expected.items():
machine = CATALOG.machines[code]
assert (machine.name, machine.specification) == (name, spec), code
def test_취득가는_원천_그대로() -> None:
raw = json.loads(
(ROOT / "resources/data_cost_input_value/mach_base_2026.json").read_text(encoding="utf-8")
)
prices = {
r["machine_code"]: r["price_thousand_krw"]
for r in raw["variables"]["mach_price"]["records"]
}
for code in ("5220-0015", "0240-0007", "6802-0040", "7930-0100"):
assert CATALOG.machines[code].price_thousand_krw == Decimal(str(prices[code]))
@@ -0,0 +1,62 @@
"""규격이 열로 선 표(관부설 12-11) — 규격 칸·비고를 읽어 줄은 서고 못 넣는 줄은 까닭으로 드러남.
2026-09-14 C 1 (브레인 차례 · 판정 불필요). 원문 = 산림사업 표준품셈 12-11-1·2·3.
원문 비고 별산·별도계산·설계수량( · 병합 포함) ** 넣는 ** 까닭을 남김
규격 (1:2) 관경 (800mm) 조인 접합몰탈·고무링이 자재 줄로
기종이 여럿인 크레인은 **고르지 않고** 규격 미정 · 원문 규격 10ton
규격 40.64 = 카탈로그 절단기 40.64
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_ResourceAxis import ( # noqa: E402
build_resource_axis,
load_combined_catalog,
load_work_item_master,
)
AXIS = build_resource_axis(load_work_item_master(), load_combined_catalog())
def _unmatched(code: str) -> dict[str, str]:
return {u.cell: u.reason for u in AXIS.unmatched if u.work_item_code == code}
def _rows(code: str) -> list:
return [r for r in AXIS.rows if r.work_item_code == code]
def test_원문_비고대로_안_넣는_줄은_까닭을_남긴다() -> None:
vr = _unmatched("FP-12-11-01")
assert "별도계산" in vr["VR관"] and "설계수량" in vr["기초콘크리트"]
assert "설계수량" in vr["거 푸 집"] # 「〃」
hume = _unmatched("FP-12-11-02")
assert "별산" in hume["흄 관"] and "설계수량" in hume["거 푸 집"] # 병합 칸
corrugated = _unmatched("FP-12-11-03")
assert "필요시적용" in corrugated["커플링밴드"] and "설계수량" in corrugated["모래부설"]
def test_규격_칸과_관경_열이_조인_키() -> None:
rings = {
r.variant: r.resource_spec for r in _rows("FP-12-11-01") if r.resource_name == "고무링"
}
assert rings == {"∅800mm": "∅800mm", "∅1000mm": "∅1000mm", "∅1200mm": "∅1200mm"}
mortar = [r for r in _rows("FP-12-11-02") if r.resource_name == "접합몰탈"]
assert {r.resource_spec for r in mortar} == {"1:2"} and len(mortar) == 3
def test_크레인은_고르지_않고_원문_규격을_보인다() -> None:
reason = _unmatched("FP-12-11-03")["크레인"]
assert "규격 미정" in reason and "원문 규격 5ton" in reason
assert not any(r.resource_name.startswith("크레인") for r in _rows("FP-12-11-03"))
def test_절단기_40_64cm_는_카탈로그_40_64() -> None:
cutters = [r for r in _rows("FP-12-11-02") if r.resource_name == "절단기"]
assert [(r.resource_code, str(r.amount)) for r in cutters] == [("7620-0003", "0.93")]
+7 -3
View File
@@ -53,10 +53,14 @@ def test_v16_불도저_운반이_갈래마다_금액으로_선다() -> None:
assert build.book.resolve(code).total > 0, code
def test_v16_덤프_운반은_아직_안_선다() -> None:
"""⚠ 안 서는 것도 사실대로 못 박는다 — 운반거리(설계 입력)를 기다리는 자리다."""
def test_v16_덤프_운반은_거리가_와야_선다() -> None:
"""⚠ 안 서는 것도 사실대로 못 박는다 — 운반은 운반거리(설계 입력)가 와야 선다.
2026-09-14 적재(10-12 1. 적재) 거리 무관이라 서고, 운반(`#L…m`)은 거리가 온 조립에만.
"""
build = cached_build()
assert not [code for code in build.book.titles if code.startswith("B-FP-10-12")]
dump = [code for code in build.book.titles if code.startswith("B-FP-10-12")]
assert dump and all(code.endswith("#적재") for code in dump)
# ── V-17 ────────────────────────────────────────────────────────────
@@ -23,6 +23,10 @@ def test_단가표_갈래_제목은_전부_마스터_갈래_키에_있다() -> N
for code in cached_build().book.titles:
if not code.startswith("B-") or "#" not in code:
continue
if code.startswith("B-FP-10-12-"):
# 덤프 운반·적재(10-12)는 **표가 아니라 본문 식**이라 마스터 표에 갈래가 없음 —
# 갈래(`#적재`·`#L…m`)는 식이 냄(2026-09-14 · `MachineProductivity_Dump`).
continue
work_item, variant = code[2:].split("#", 1)
keys = {
normalize_variant_key(k) for k in nodes.get(work_item, {}).get("variant_keys") or []
+1
View File
@@ -26,6 +26,7 @@ export const ui_locales_b3 = {
B09_Sheet_Won: ["원", " KRW"],
B09_Sheet_Count: ["건", ""],
B09_Sheet_Unconfirmed: ["미확정", "Unconfirmed"],
B09_Sheet_Unpriced: ["금액에 안 들어감", "not in the amount"],
B09_Sheet_Missing: ["금액을 못 세운 줄", "Rows without a price"],
B09_Sheet_Reload: ["다시 불러오기", "Reload"],
B09_Sheet_Level: ["보이는 레벨", "Show levels"],