Merge remote-tracking branches 'origin/main_desktop_1' and 'origin/main_laptop_1' into sub_desktop_1
This commit is contained in:
@@ -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)"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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가 새 위치로 시설 정보를 승계한다.
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")]
|
||||
@@ -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 []
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user