Compare commits
9
Commits
sub_desktop_1
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b516ffed66 | ||
|
|
15d91be7b0 | ||
|
|
0fa6cd0898 | ||
|
|
0989471901 | ||
|
|
6ea73956e5 | ||
|
|
8ed4be855b | ||
|
|
20597071bd | ||
|
|
fd0ea82975 | ||
|
|
8dddc76839 |
@@ -83,6 +83,8 @@ if (input.haul_plan_for) {
|
||||
structure_spoil_points: input.context?.structure_spoil_points ?? null,
|
||||
// 잔토는 자연상태로 오고 곡선은 다짐상태다 — 담기 전에 ×C 하는 데 쓴다.
|
||||
conversion: input.context?.earthwork_conversion ?? null,
|
||||
// 화면이 그리는 계획 — 잔진동을 거른다(수량은 저장 정본 `haul_plan` 이 따로 낸다).
|
||||
drawing: true,
|
||||
});
|
||||
writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null }));
|
||||
process.exit(0);
|
||||
@@ -104,18 +106,27 @@ const result = conversion
|
||||
)
|
||||
: null;
|
||||
// 배분은 **서버만** 만든다 — 그래야 그 코드가 브라우저 번들에서 빠진다(2026-09-06).
|
||||
const plan = result
|
||||
? computeHaulPlan(result, input.context?.haul_equipment_limits, {
|
||||
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
|
||||
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
|
||||
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
|
||||
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
|
||||
structure_spoil_points: input.context?.structure_spoil_points ?? null,
|
||||
conversion: conversion ?? null,
|
||||
})
|
||||
: null;
|
||||
// 두 벌을 남긴다 — `haul_plan` 은 **거르지 않은** 수량 정본(B08), `haul_plan_drawing` 은
|
||||
// 잔진동을 거른 그림(B07 토적도). 거르기가 수량에 닿으면 운반이 사라진다(2026-09-14 브레인 ①).
|
||||
const planFor = (drawing: boolean) =>
|
||||
result
|
||||
? computeHaulPlan(result, input.context?.haul_equipment_limits, {
|
||||
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
|
||||
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
|
||||
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
|
||||
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
|
||||
structure_spoil_points: input.context?.structure_spoil_points ?? null,
|
||||
conversion: conversion ?? null,
|
||||
drawing,
|
||||
})
|
||||
: null;
|
||||
const plan = planFor(false);
|
||||
const drawingPlan = planFor(true);
|
||||
const massHaul = result
|
||||
? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null)
|
||||
? massHaulPayload(result, {
|
||||
...(plan ? { haul_plan: haulPlanPayload(plan) } : {}),
|
||||
...(drawingPlan ? { haul_plan_drawing: haulPlanPayload(drawingPlan) } : {}),
|
||||
})
|
||||
: null;
|
||||
|
||||
// 선 다단 벽 목록(④) — 관 연장처럼 기하가 세운 결과를 정본에 남겨 B08 이 줄을 세움.
|
||||
|
||||
@@ -520,7 +520,9 @@ def build_mass_haul_drawing(
|
||||
if curve_entity:
|
||||
entities.append(curve_entity)
|
||||
|
||||
plan = mass_haul.get("haul_plan")
|
||||
# 그림은 잔진동을 거른 계획을 그린다 — 수량 정본(`haul_plan`)은 거르지 않아 balloon 이
|
||||
# 너무 많다(2026-09-14 브레인 ①). 옛 저장분은 그림용이 없어 `haul_plan` 을 그린다.
|
||||
plan = mass_haul.get("haul_plan_drawing") or mass_haul.get("haul_plan")
|
||||
if isinstance(plan, dict):
|
||||
entities.extend(_band_entities(drawing_id, plan, curve, mm_h))
|
||||
entities.extend(_residual_entities(drawing_id, plan, curve, interval_m, mm_h))
|
||||
|
||||
@@ -110,6 +110,11 @@ FORM_JUDGMENTS: dict[str, tuple[str, str, str]] = {
|
||||
# 절 제목으로 읽어 12-2 에 붙어 있던 표(빌더가 앞 절 이어받기로 고침). 형태 판정은 그대로.
|
||||
"F0358": ("12-17-1", "reference", "시설유형 Type-Ⅰ~Ⅳ 적용 기준 설명"),
|
||||
"F0360": ("12-17-1", "reference", "현장조건 Type-Ⅰ~Ⅲ 적용 기준 설명"),
|
||||
"F0385": (
|
||||
"12-34-1",
|
||||
"requirement",
|
||||
"인력(인)·기계(대, Q=5.4㎥/hr) 소요량 표 — 「별도계상」 은 레미콘 자재 줄 비고일 뿐",
|
||||
),
|
||||
"F0388": ("12-34-4", "reference", "「재료비 JOINT FILLER」 항목만 — 값이 없는 구성 안내"),
|
||||
"F0390": ("12-36", "reference", "제작비·운송비·설치비 「견적처리」"),
|
||||
"F0392": ("12-38-1", "coefficient", "사용횟수별 잔존율(12회 25%·25회 10%)"),
|
||||
|
||||
@@ -55,6 +55,33 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = {
|
||||
"(제안 무한궤도 — 영월 실무 「06M3 B/H」 · 2026-09-14 브레인 ②).",
|
||||
),
|
||||
# 2026-09-14 ㉮ — 사용횟수 갈래로 푼 뒤 남는 원문 몫. 값을 짓지 않고 말만.
|
||||
# 봉상후렉시블 셋(2026-09-14) — 판정표가 콘크리트 ㎥ 줄만 읽음 · 표의 나머지 줄이 조용히 안 사라지게 원문 그대로.
|
||||
"FP-12-12": (
|
||||
"표의 다른 줄",
|
||||
"ⓘ 이 일위대가는 콘크리트 ㎥(콘크리트공·보통인부 + 진동기) 몫만 — 표의 거푸집(합판4회) · 철근(SD300 D13"
|
||||
" · 철근가공조립 간단) · 기초잡석 운반·부설·다짐(소할 30% 브레이커 · 적사 굴착기 0.7㎥ · 운반 덤프트럭"
|
||||
" 15톤 · 부설다짐 보통인부 0.6인/㎥) · 바닥정리는 안 넣음(따로 공종) · [주] 성토부 날개벽 인건비 30%"
|
||||
" 할증은 안 걺.",
|
||||
),
|
||||
"FP-12-13": (
|
||||
"표의 다른 줄",
|
||||
"ⓘ 이 일위대가는 콘크리트 ㎥(콘크리트공·보통인부 + 진동기) 몫만 — 표의 거푸집(합판4회) · 기초잡석"
|
||||
" 운반·부설·다짐(소할 30% 브레이커 · 적사 굴착기 0.7㎥ · 운반 덤프트럭 15톤 · 부설다짐 보통인부"
|
||||
" 0.6인/㎥)은 안 넣음(따로 공종).",
|
||||
),
|
||||
"FP-12-16": (
|
||||
"표의 다른 줄",
|
||||
"ⓘ 이 일위대가는 구체·버림콘크리트 ㎥ 몫만 — 표의 원형거푸집(PE10회 · 각 1조/10) · 설치비(기초 및"
|
||||
" 슬라브 특별인부 0.16·보통인부 0.45인/㎡ · 벽체 0.20·0.60인/㎡) · 거푸집(합판4회) · 철근(가공조립"
|
||||
" 보통) · 설치비 재료비의 5% · 사다리설치(철근가공조립 간단 준용) · 인력 보통인부 0.004 는 안 넣음"
|
||||
"(따로 공종·조립 몫).",
|
||||
),
|
||||
"FP-12-34-01": (
|
||||
"원문 머리",
|
||||
"ⓘ 원문 머리 「(단위: 개소당)」 ↔ 인력 콘크리트공 0.17·보통인부 0.29 가 12-16 맨홀 ㎥당(0.17인/㎥"
|
||||
" · 0.29인/㎥)과 같고 기계도 Q=5.4㎥/hr ⇒ ㎥당으로 읽음(고른 쪽 ㎥당 · 버린 쪽 개소당) ·"
|
||||
" 봉상후렉시블(45mm) 대 2 는 엔진식 진동기의 봉이라 두 번 안 셈(2026-09-14 브레인).",
|
||||
),
|
||||
"FP-12-04": (
|
||||
"원문 [주]",
|
||||
"ⓘ 「사용고재 평가기준 23%(합판과 각재의 설계단가 기준)」 은 원문이 셈을 안 줘 값으로 안 씀"
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
(1-4-1 「어린나무가꾸기에 한하여」 · 1-4-2 「줄베기」 · 1-4-9 「숲가꾸기 및 병해충방제
|
||||
작업로」…). **임도 토공에 붙이라는 지시가 원문에 없다.** 그래서 켜는 것은 사용자 몫이고,
|
||||
각 계열의 **[주] 원문을 화면에 그대로** 띄워 어디에 쓰라는 표인지 보이게 한다.
|
||||
㉡ **여럿을 고를 때 합산인가 곱인가** — 원문에 없다. 지금은 **합산**으로 두고 그 사실을
|
||||
화면 근거에 적는다(실무 서식이 대개 합산이나 원문 근거는 아니다).
|
||||
㉡ **여럿을 고를 때 합산인가 곱인가** — 산림품셈 1-4 에는 없고 **건설 공통 1-4-2 「할증의
|
||||
중복가산요령」** 이 정함(교차 참조 · 2026-09-14 브레인 672): 「W = 기본품 × (1 + a1 + … + an)
|
||||
· 단, 동일성격의 품할증요소의 이중적용은 불가」 → **합산**. 「동일성격」이 어느 계열끼리인지는
|
||||
원문이 안 정해 막지 않고 단서를 화면에 보임(설계자가 가림).
|
||||
|
||||
**기본은 「안 고름」** — 한 계열도 안 고르면 금액이 한 원도 안 움직인다.
|
||||
|
||||
@@ -46,13 +48,14 @@ _NOTE_LOOKAHEAD = 12
|
||||
_RE_PERCENT = re.compile(r"^-?\d+(?:\.\d+)?%$")
|
||||
_RE_SECTION = re.compile(r"^(1-4-\d+)\.\s*(.+)$")
|
||||
|
||||
#: ⚠ **여럿을 고를 때 어떻게 셈하나 — 원문이 안 정한 자리다.**
|
||||
#: 지금은 「합산」이고 **여기 한 곳만 갈아 끼우면 바뀐다**(코드 깊이 박지 않는다).
|
||||
#: 여럿을 고를 때 셈법 — 건설 공통 1-4-2 「W = 기본품 × (1 + a1 + … + an)」 합산(교차 참조 · 672).
|
||||
#: **여기 한 곳**이 정한다(코드 깊이 박지 않는다).
|
||||
#: `"sum"` = 10% + 5% = 15% · `"product"` = 1.10 × 1.05 − 1 = 15.5%
|
||||
COMBINE_RULE = "sum"
|
||||
COMBINE_NOTE = (
|
||||
"⚠ 여럿을 고르면 더합니다 — 원문이 합산인지 곱인지 안 정해 우리가 그렇게 두었습니다"
|
||||
" (사용자 확정 대기)."
|
||||
"여럿을 고르면 합산 — 건설 공통 1-4-2 「W = 기본품 × (1 + a1 + a2 + … + an)」"
|
||||
"(산림품셈 1-4 에 겹침 규정이 없어 교차 참조) · ⚠ 같은 조 단서 「동일성격의 품할증요소의"
|
||||
" 이중적용은 불가」 — 어느 계열끼리 동일성격인지는 원문이 안 정해 설계자가 가림"
|
||||
)
|
||||
SEAT_NOTE = (
|
||||
"품 할인·할증은 품(인력) 줄에 붙습니다 — 물량에 곱하면 자재·기계까지 부풀어"
|
||||
|
||||
@@ -63,6 +63,39 @@ JUDGED_TABLES: dict[str, dict[str, Any]] = {
|
||||
"needs_machine": {"구체콘크리트": "다짐:봉상후렉시블(45mm)"},
|
||||
"why": "원문 L6460 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
|
||||
},
|
||||
# 12-15 와 같은 모양 셋 — 같은 봉상후렉시블 줄 하나가 셋을 막고 있었음(2026-09-14 브레인 · 672 다음).
|
||||
"F0350": {
|
||||
"code": "FP-12-12",
|
||||
"shape": "remark_labor",
|
||||
"prefix": "날개벽",
|
||||
"needs_machine": {"콘크리트": "다짐:봉상후렉시블(45mm)"},
|
||||
"why": "원문 L6419 12-12 「콘크리트(레미콘) ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
|
||||
},
|
||||
"F0351": {
|
||||
"code": "FP-12-13",
|
||||
"shape": "remark_labor",
|
||||
"prefix": "면벽",
|
||||
"needs_machine": {"콘크리트": "다짐:봉상후렉시블(45mm)"},
|
||||
"why": "원문 L6436 12-13 「콘크리트(레미콘) ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
|
||||
},
|
||||
"F0354": {
|
||||
"code": "FP-12-16",
|
||||
"shape": "remark_labor",
|
||||
"prefix": "맨홀",
|
||||
# 칸이 하나 밀려 비고가 끝 칸이 아님(「구체콘크리트 | 철근 | ㎥ | | 비고 | 」).
|
||||
"needs_machine": {"구체콘크리트": "봉상후렉시블(45mm)"},
|
||||
"why": "원문 L6474 12-16 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
|
||||
},
|
||||
# 12-34-1 — 머리 「(단위: 개소당)」 이나 인력 0.17·0.29 가 12-16 맨홀 ㎥당과 같고 기계가 Q ㎥/hr
|
||||
# ⇒ ㎥당으로 읽음 · 「봉상후렉시블 대 2」 는 엔진식 진동기(엔진+플렉시블 한 대)의 봉(2026-09-14 브레인).
|
||||
"F0385": {
|
||||
"code": "FP-12-34-01",
|
||||
"shape": "per_m3_rows",
|
||||
"prefix": "콘크리트 타설",
|
||||
# 엔진식 진동기(건설품셈 8-3 (4611) 엔진+플렉시블 한 대)의 봉 — 「진동기(3.5HP) 대 2」 로 셈.
|
||||
"same_machine": ("봉상후렉시블(45mm)",),
|
||||
"why": "원문 L6839 12-34-1 「인력 콘크리트공·보통인부 인 · 기계 대 (Q=5.4㎥/hr)」",
|
||||
},
|
||||
}
|
||||
#: 비고 칸 인력 — 「콘크리트공0.24인/㎥, 보통인부 0.42인/㎥」.
|
||||
_RE_REMARK_LABOR = re.compile(r"([가-힣]+)\s*(\d+(?:\.\d+)?)\s*인/㎥")
|
||||
@@ -122,7 +155,8 @@ def match_judged_table(
|
||||
result.partial_items[code] = why
|
||||
return True
|
||||
|
||||
if basis_quantity in (None, 0):
|
||||
# 비고·Q 가 ㎥당을 적는 모양은 표 머리 밑수를 안 씀(12-12 날개벽은 「개소당」 머리조차 없음).
|
||||
if basis_quantity in (None, 0) and judged["shape"] not in ("remark_labor", "per_m3_rows"):
|
||||
return block("판정 표에 밑수가 없습니다")
|
||||
if judged["shape"] == "header_row":
|
||||
staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit)
|
||||
@@ -130,6 +164,8 @@ def match_judged_table(
|
||||
staged = _use_count(code, table, rows, catalog, basis_quantity, unit)
|
||||
elif judged["shape"] == "remark_labor":
|
||||
staged = _remark_labor(code, table, judged, rows, catalog)
|
||||
elif judged["shape"] == "per_m3_rows":
|
||||
staged = _per_m3_rows(code, table, judged, rows, catalog)
|
||||
else:
|
||||
staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit)
|
||||
if isinstance(staged, str):
|
||||
@@ -240,7 +276,7 @@ def _remark_labor(code, table, judged, rows, catalog) -> list | str:
|
||||
by_name = {"".join(cells[0].split()): cells for cells in rows if cells}
|
||||
staged: list = []
|
||||
for index, cells in enumerate(rows):
|
||||
labors = _RE_REMARK_LABOR.findall(cells[-1] if cells else "")
|
||||
labors = _RE_REMARK_LABOR.findall(" ".join(cells[3:])) # 비고가 끝 칸이 아닌 표(12-16)
|
||||
if len(cells) < 3 or cells[2] != "㎥" or not labors:
|
||||
continue
|
||||
variant = cells[0].split("(")[0].strip()
|
||||
@@ -268,3 +304,27 @@ def _remark_labor(code, table, judged, rows, catalog) -> list | str:
|
||||
for entry, amount in pieces:
|
||||
staged.append(_row(code, table, entry, amount, "㎥", index, variant))
|
||||
return staged
|
||||
|
||||
|
||||
def _per_m3_rows(code, table, judged, rows, catalog) -> list | str:
|
||||
"""「인」 칸 앞 이름 · 뒤 수(인/㎥) · 「대」 칸 앞 이름 · 뒤 대수 ÷ Q — 칸이 밀린 줄도 단위 칸으로 찾음."""
|
||||
table_id = str(table.get("pum_table_id", ""))
|
||||
staged: list = []
|
||||
for index, cells in enumerate(rows):
|
||||
unit = next((i for i, c in enumerate(cells) if c in ("인", "대") and i > 0), None)
|
||||
if unit is None:
|
||||
continue
|
||||
name = cells[unit - 1]
|
||||
if "".join(name.split()) in judged.get("same_machine", {}):
|
||||
continue # 같은 기계 두 번 안 셈 — 까닭은 공종 사유 한 줄(`KnownGaps`)이 화면에 보임
|
||||
amount = next((parse_amount(c) for c in cells[unit + 1 :] if parse_amount(c)), None)
|
||||
entry = _entry(catalog, name, code)
|
||||
if amount is None or entry is None:
|
||||
return f"{name} 줄"
|
||||
if cells[unit] == "대":
|
||||
found_q = _RE_Q.search(" ".join(cells))
|
||||
if found_q is None:
|
||||
return f"{name} Q"
|
||||
amount = amount / Decimal(found_q.group(1))
|
||||
staged.append(_row(code, table, entry, amount, "㎥", index, ""))
|
||||
return staged
|
||||
|
||||
@@ -899,9 +899,13 @@ def build_unit_prices(
|
||||
# 예외 = 사용자가 다른 품셈 밑수를 확정한 자리(9-21 「1,000㎡당」 · ÷ 는 ChooseOne 이 검) —
|
||||
# 마스터 목록은 원문 사실이라 그대로 두고 여기서만 걷음(2026-09-14 브레인 · 사유는 비고).
|
||||
borrowed = work_item_code.startswith(tuple(BORROWED_BASIS_PER))
|
||||
from B09_Estimation.B09_Estimation_ResourceAxis_JudgedTable import JUDGED_TABLES
|
||||
|
||||
for row in rows:
|
||||
section = missing_basis.get(str(row.pum_table_id))
|
||||
if section and not borrowed:
|
||||
# 비고가 「인/㎥」 로 밑수를 적은 판정표(12-12 날개벽 「개소당」 머리 없음)는 밑수가 ㎥ 로 섬.
|
||||
per_remark = JUDGED_TABLES.get(str(row.pum_table_id), {}).get("shape") == "remark_labor"
|
||||
if section and not borrowed and not per_remark:
|
||||
build.basis_missing[work_item_code] = section
|
||||
break
|
||||
|
||||
|
||||
@@ -220,8 +220,10 @@ export interface HaulPlan {
|
||||
}
|
||||
|
||||
/**
|
||||
* 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면 balloon이
|
||||
* 수십 개 깔려 도면을 못 읽는다. 곡선 진폭 대비 비율이라 노선 규모에 자동으로 맞는다.
|
||||
* **그림에서만** 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면
|
||||
* balloon이 수십 개 깔려 도면을 못 읽는다. 곡선 진폭 대비 비율이라 노선 규모에 자동으로 맞는다.
|
||||
* ⚠ 수량 계획에는 걸지 않는다 — 진폭이 커지면 작은 봉우리가 통째로 지워져 운반량이 0 이 됐다
|
||||
* (2026-09-14 936be972 실측 474.14㎥ → 0 · 브레인 ①).
|
||||
*/
|
||||
const MIN_SWING_RATIO = 0.02;
|
||||
|
||||
@@ -603,14 +605,17 @@ export function computeHaulPlan(
|
||||
/** 토량환산계수 — 구조물 잔토(자연상태)를 이 곡선의 **다짐상태**로 옮길 때만 쓴다.
|
||||
* 안 오면 환산 없이 담긴다(값을 지어내지 않는다). */
|
||||
conversion?: EarthworkConversion | null;
|
||||
/** 그림용 — 잔진동을 거른다(`MIN_SWING_RATIO`). 안 오면 **거르지 않는다**(수량 계획). */
|
||||
drawing?: boolean;
|
||||
},
|
||||
): HaulPlan | null {
|
||||
const points = result.points;
|
||||
if (points.length < 2) return null;
|
||||
|
||||
const range = Math.max(result.max_cumulative_m3 - result.min_cumulative_m3, 0);
|
||||
const minSwing = Math.max(range * MIN_SWING_RATIO, 1);
|
||||
const extrema = pruneExtrema(points, extremaIndices(points), minSwing);
|
||||
const extrema = options?.drawing
|
||||
? pruneExtrema(points, extremaIndices(points), Math.max(range * MIN_SWING_RATIO, 1))
|
||||
: extremaIndices(points);
|
||||
const tiers = sortedLimits(limits);
|
||||
|
||||
const blocks: HaulBlock[] = [];
|
||||
|
||||
@@ -51,6 +51,38 @@
|
||||
"pum_edition": "2026-01-01",
|
||||
"basis": "12-15 집수정 표(원문 L6460)는 「봉상후렉시블(45mm) Q=5.4㎥/hr」 만 적고 전기·엔진을 안 적음 — 건설품셈 8-3 (4611) 은 전기식 플렉시블형 ø45(0.75㎾)·엔진식 플렉시블형 ø45(2.6㎾) 둘 · 12-34-1 「콘크리트 진동기(3.5HP)」 = 2.6㎾ 엔진식 · 운전경비표(8-4)에 엔진식 4611-0350(휘발유 1.0L)만 있음 → 엔진식. 전기식이 필요하면 그때 엶. 2026-09-14 브레인 승인(661 뒤 ①)"
|
||||
},
|
||||
{
|
||||
"axis": "resource",
|
||||
"from": "다짐:봉상후렉시블(45mm)",
|
||||
"to": "4611-0350",
|
||||
"scope": "FP-12-12",
|
||||
"pum_edition": "2026-01-01",
|
||||
"basis": "12-12 날개벽(원문 L6419) 표는 「봉상후렉시블(45mm) Q=5.4㎥/hr」 만 적고 전기·엔진을 안 적음 — 12-15 집수정과 같은 줄 · 같은 근거(건설품셈 8-3 (4611) 전기식·엔진식 둘 · 12-34-1 「콘크리트 진동기(3.5HP)」 = 2.6㎾ 엔진식 · 운전경비표에 엔진식 4611-0350 만) → 엔진식. 2026-09-14 브레인(672 다음 · 봉상후렉시블 셋)"
|
||||
},
|
||||
{
|
||||
"axis": "resource",
|
||||
"from": "다짐:봉상후렉시블(45mm)",
|
||||
"to": "4611-0350",
|
||||
"scope": "FP-12-13",
|
||||
"pum_edition": "2026-01-01",
|
||||
"basis": "12-13 면벽(원문 L6436) 표는 「봉상후렉시블(45mm) Q=5.4㎥/hr」 만 적고 전기·엔진을 안 적음 — 12-15 집수정과 같은 줄 · 같은 근거(건설품셈 8-3 (4611) 전기식·엔진식 둘 · 12-34-1 「콘크리트 진동기(3.5HP)」 = 2.6㎾ 엔진식 · 운전경비표에 엔진식 4611-0350 만) → 엔진식. 2026-09-14 브레인(672 다음 · 봉상후렉시블 셋)"
|
||||
},
|
||||
{
|
||||
"axis": "resource",
|
||||
"from": "봉상후렉시블(45mm)",
|
||||
"to": "4611-0350",
|
||||
"scope": "FP-12-16",
|
||||
"pum_edition": "2026-01-01",
|
||||
"basis": "12-16 맨홀(원문 L6474) 표는 「봉상후렉시블(45mm) Q=5.4㎥/hr」 만 적고 전기·엔진을 안 적음 — 12-15 집수정과 같은 줄 · 같은 근거(건설품셈 8-3 (4611) 전기식·엔진식 둘 · 12-34-1 「콘크리트 진동기(3.5HP)」 = 2.6㎾ 엔진식 · 운전경비표에 엔진식 4611-0350 만) → 엔진식. 2026-09-14 브레인(672 다음 · 봉상후렉시블 셋)"
|
||||
},
|
||||
{
|
||||
"axis": "resource",
|
||||
"from": "콘크리트 진동기(3.5HP)",
|
||||
"to": "4611-0350",
|
||||
"scope": "FP-12-34-01",
|
||||
"pum_edition": "2026-01-01",
|
||||
"basis": "12-34-1 「콘크리트 진동기(3.5HP)」 — 3.5HP = 2.6㎾ · 건설품셈 8-3 (4611) 엔진식 플렉시블형 ø45(2.6㎾) · 운전경비표에 엔진식만 · 실무 영월 중기목록 「콘크리트 진동기 45φ(2.6㎾)엔진식플렉시블형」. 2026-09-14 브레인"
|
||||
},
|
||||
{
|
||||
"axis": "variant",
|
||||
"from": "0.2·소림",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "data_work_item_master_manifest",
|
||||
"generated_at": "2026-09-14T22:25:14+09:00",
|
||||
"generated_at": "2026-09-14T23:14:39+09:00",
|
||||
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
|
||||
"source": {
|
||||
"dataset_id": "pum_forest",
|
||||
@@ -12,8 +12,8 @@
|
||||
"files": [
|
||||
{
|
||||
"file": "work_item_master_2026-01-01.json",
|
||||
"sha256": "2f0d8bfb3836597ee6f2fd755e2f2e172cb33df1e1e9061af81982a43497f9b2",
|
||||
"size_bytes": 838655
|
||||
"sha256": "708a6a80d016c42d3402bd2a6cb5266fa18e1e2b5301bac823e6ff79d17fb2b6",
|
||||
"size_bytes": 838969
|
||||
},
|
||||
{
|
||||
"file": "form_undetermined_2026-01-01.json",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"dataset_id": "work_item_master_forest",
|
||||
"effective_date": "2026-01-01",
|
||||
"pum_edition": "2026-01-01",
|
||||
"generated_at": "2026-09-14T22:25:14+09:00",
|
||||
"generated_at": "2026-09-14T23:14:39+09:00",
|
||||
"dataset_version": {
|
||||
"dataset_id": "pum_forest",
|
||||
"effective_date": "2026-01-01",
|
||||
@@ -32888,7 +32888,9 @@
|
||||
"formula_rows": [],
|
||||
"special_glyphs": [],
|
||||
"capacity_formula_here": false,
|
||||
"variant_key": [],
|
||||
"variant_key": [
|
||||
"콘크리트"
|
||||
],
|
||||
"condition_note": [
|
||||
"구 분",
|
||||
"규 격",
|
||||
@@ -32970,7 +32972,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"variant_keys": []
|
||||
"variant_keys": [
|
||||
"콘크리트"
|
||||
]
|
||||
},
|
||||
{
|
||||
"work_item_code": "FP-12-13",
|
||||
@@ -32997,7 +33001,9 @@
|
||||
"formula_rows": [],
|
||||
"special_glyphs": [],
|
||||
"capacity_formula_here": false,
|
||||
"variant_key": [],
|
||||
"variant_key": [
|
||||
"콘크리트"
|
||||
],
|
||||
"condition_note": [
|
||||
"구 분",
|
||||
"규 격",
|
||||
@@ -33065,7 +33071,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"variant_keys": []
|
||||
"variant_keys": [
|
||||
"콘크리트"
|
||||
]
|
||||
},
|
||||
{
|
||||
"work_item_code": "FP-12-14",
|
||||
@@ -33242,7 +33250,10 @@
|
||||
"formula_rows": [],
|
||||
"special_glyphs": [],
|
||||
"capacity_formula_here": false,
|
||||
"variant_key": [],
|
||||
"variant_key": [
|
||||
"구체콘크리트",
|
||||
"버림콘크리트"
|
||||
],
|
||||
"condition_note": [
|
||||
"구 분",
|
||||
"단위",
|
||||
@@ -33349,7 +33360,10 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"variant_keys": []
|
||||
"variant_keys": [
|
||||
"구체콘크리트",
|
||||
"버림콘크리트"
|
||||
]
|
||||
},
|
||||
{
|
||||
"work_item_code": "FP-12-17",
|
||||
@@ -35258,8 +35272,8 @@
|
||||
"pum_table_id": "F0385",
|
||||
"section": "12-34-1. 콘크리트 타설(철근 진동기포함)",
|
||||
"source_line": 6843,
|
||||
"pum_form": "reference",
|
||||
"form_basis": "'별도계상' — 값이 아니라 참조 지시",
|
||||
"pum_form": "requirement",
|
||||
"form_basis": "사람 판정 — 인력(인)·기계(대, Q=5.4㎥/hr) 소요량 표 — 「별도계상」 은 레미콘 자재 줄 비고일 뿐",
|
||||
"basis_quantity": 1.0,
|
||||
"basis_unit": "개소",
|
||||
"basis_source": "본문",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""봉상후렉시블 셋 + 12-34-1 — 2026-09-14 브레인(672 다음).
|
||||
|
||||
같은 「봉상후렉시블(45mm) Q=5.4㎥/hr」 줄 하나가 12-12 날개벽 · 12-13 면벽 · 12-16 맨홀을 막고 있었음.
|
||||
세 표는 12-15 집수정과 같은 모양(㎥ 줄 비고 인력 + 다짐기 줄) — 판정표 한 벌에 더하고 엔진식 4611-0350 로 이음.
|
||||
실무 영월 「콘크리트타설(임도품셈 5-5)」 호표가 같은 모양: 콘크리트공 0.15·보통인부 0.27 인/㎥ + 진동기 1/Q(5.4)
|
||||
· 진동기 45φ(2.6㎾)엔진식플렉시블형 손료 252천원 × 0.5101 · 노무 0 — 엔진식·계수·조종원 없음이 실무 실증.
|
||||
|
||||
12-34-1 콘크리트 타설(철근 진동기 포함) — 실무 호표 없음 → 브레인 판정
|
||||
① 「콘크리트 진동기(3.5HP) 대 2」 만 4611-0350 × 2대 · 「봉상후렉시블(45mm) 대 2」 는 같은 기계의 봉이라 안 셈
|
||||
② 머리 「(단위: 개소당)」 ↔ 인력 0.17·0.29 가 12-16 맨홀 ㎥당과 같고 기계도 Q ㎥/hr ⇒ ㎥당으로 읽고 사유에 나란히
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of
|
||||
|
||||
VIBRATOR = "X-4611-0350"
|
||||
PER_Q = Decimal(1) / Decimal("5.4")
|
||||
|
||||
|
||||
def _rows(build, code: str) -> dict[str, Decimal]:
|
||||
return {
|
||||
r["ref_code"]: Decimal(r["quantity"])
|
||||
for r in detail_of(build, code)["rows"]
|
||||
if r.get("ref_code")
|
||||
}
|
||||
|
||||
|
||||
def test_날개벽_면벽은_콘크리트_갈래가_진동기와_함께_섬() -> None:
|
||||
build = cached_build()
|
||||
for code in ("B-FP-12-12#콘크리트", "B-FP-12-13#콘크리트"):
|
||||
rows = _rows(build, code)
|
||||
assert rows[VIBRATOR] == PER_Q and rows["1013"] == Decimal("0.15"), (code, rows)
|
||||
assert rows["1002"] == Decimal("0.27"), (code, rows)
|
||||
assert build.book.titles[code].unit == "㎥"
|
||||
assert (
|
||||
"FP-12-12" not in build.basis_missing
|
||||
) # 비고가 인/㎥ — 「개소당」 머리가 없어도 밑수는 ㎥
|
||||
|
||||
|
||||
def test_맨홀은_비고가_끝_칸이_아니어도_구체_버림_갈래() -> None:
|
||||
build = cached_build()
|
||||
body = _rows(build, "B-FP-12-16#구체콘크리트")
|
||||
assert (
|
||||
body[VIBRATOR] == PER_Q
|
||||
and body["1013"] == Decimal("0.17")
|
||||
and body["1002"] == Decimal("0.29")
|
||||
)
|
||||
lean = _rows(build, "B-FP-12-16#버림콘크리트")
|
||||
assert lean == {"1013": Decimal("0.15"), "1002": Decimal("0.27")}, lean
|
||||
for code in ("FP-12-12", "FP-12-13", "FP-12-16"):
|
||||
assert "봉상후렉시블" not in " ".join(build.unattached.get(code, [])), code
|
||||
|
||||
|
||||
def test_12_34_1_진동기_두_대만_세고_봉은_두_번_안_셈() -> None:
|
||||
build = cached_build()
|
||||
rows = _rows(build, "B-FP-12-34-01")
|
||||
assert rows[VIBRATOR] == 2 * PER_Q, rows
|
||||
assert rows["1013"] == Decimal("0.17") and rows["1002"] == Decimal("0.29"), rows
|
||||
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
|
||||
|
||||
assert "봉상후렉시블" in known_gap_note("FP-12-34-01") and "두 번 안 셈" in known_gap_note(
|
||||
"FP-12-34-01"
|
||||
)
|
||||
refs = [r.get("ref_code") for r in detail_of(build, "B-FP-12-34-01")["rows"]]
|
||||
assert refs.count(VIBRATOR) == 1, refs # 봉 줄이 기계로 또 안 섬(한 줄 · 2대 몫)
|
||||
|
||||
|
||||
def test_12_34_1_은_개소당_머리를_세제곱미터당으로_읽은_까닭을_보임() -> None:
|
||||
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
|
||||
|
||||
build = cached_build()
|
||||
assert build.book.titles["B-FP-12-34-01"].unit == "㎥"
|
||||
note = known_gap_note("FP-12-34-01")
|
||||
assert "개소당" in note and "㎥당" in note and "12-16" in note, note
|
||||
|
||||
|
||||
def test_표의_나머지_줄은_조용히_안_사라지고_사유에_원문_그대로() -> None:
|
||||
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
|
||||
|
||||
assert "원형거푸집" in known_gap_note("FP-12-16") and "0.16" in known_gap_note("FP-12-16")
|
||||
assert "기초잡석" in known_gap_note("FP-12-12") and "30%" in known_gap_note("FP-12-12")
|
||||
assert "기초잡석" in known_gap_note("FP-12-13")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""품 할증 겹침 — 2026-09-14 브레인 672: 건설 공통 1-4-2 「할증의 중복가산요령」.
|
||||
|
||||
원문: 「W = 기본품 × (1 + a1 + a2 + a3 + … + an) · 단, 동일성격의 품할증요소의 이중적용은 불가함」
|
||||
산림품셈 1-4 에는 겹침 규정이 없어 **교차 참조**로 건설 규정을 따름 — 합산은 이제 「우리가 둔 것」이 아니라 원문.
|
||||
「동일성격」이 어느 계열끼리인지는 원문이 안 정함 → 막지 않고 설계자에게 그 단서를 그대로 보임.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import B09_Estimation.B09_Estimation_LaborSurcharge as LS
|
||||
|
||||
|
||||
def test_여럿을_고르면_합산이고_근거는_건설_1_4_2() -> None:
|
||||
assert LS.COMBINE_RULE == "sum"
|
||||
picks = LS.parse_choices({"1-4-1": "1-4-1:0", "1-4-5": "1-4-5:1"}) # 10% · 5%
|
||||
assert LS.total_percent(picks)[0] == Decimal(15)
|
||||
note = LS.COMBINE_NOTE
|
||||
assert "1-4-2" in note and "1 + a1 + a2" in note and "교차 참조" in note, note
|
||||
assert "확정 대기" not in note and "우리가 그렇게" not in note, note
|
||||
|
||||
|
||||
def test_동일성격_이중적용_불가_단서를_보임() -> None:
|
||||
assert "동일성격" in LS.COMBINE_NOTE and "이중적용" in LS.COMBINE_NOTE
|
||||
assert "설계자" in LS.COMBINE_NOTE # 어느 계열이 동일성격인지는 원문이 안 정함
|
||||
@@ -0,0 +1,199 @@
|
||||
"""유토 배분 — **수량은 거르지 않은 계획**, 잔진동 거르기는 **그림에만** (2026-09-14 브레인 ①).
|
||||
|
||||
실측(936be972 · ㉳ 샘플 넓힘 뒤): 성토가 늘어 곡선 진폭이 커지자 「진폭 × 2%」 거르기가
|
||||
작은 절토 봉우리를 통째로 지워 **운반량 474.14㎥ → 0** 이 됐다. 거르기는 balloon 이
|
||||
수십 개 깔리는 것을 막으려고 둔 **도면용 손질**이라 수량에 닿으면 안 된다.
|
||||
|
||||
① `computeHaulPlan` 기본은 거르지 않는다 — 작은 봉우리도 블록(운반)으로 남는다.
|
||||
② `drawing: true` 일 때만 거른다 — 그림은 종전대로 깔끔하다.
|
||||
③ 서버 정본은 둘을 따로 둔다 — `haul_plan`(수량) · `haul_plan_drawing`(토적도·화면).
|
||||
④ B07 토적도는 그림용을 먼저 읽는다(없으면 옛 저장분 `haul_plan`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { computeHaulPlan } from "./common_util_mass_haul_balance.js";
|
||||
|
||||
const [inputPath, outputPath] = process.argv.slice(2);
|
||||
const input = JSON.parse(readFileSync(inputPath, "utf8"));
|
||||
const out = input.cases.map((options) => {
|
||||
const plan = computeHaulPlan(input.result, null, options);
|
||||
return plan && {
|
||||
hauled_m3: plan.hauled_m3, borrow_m3: plan.borrow_m3, blocks: plan.blocks.length,
|
||||
};
|
||||
});
|
||||
writeFileSync(outputPath, JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _bump_then_fill() -> dict:
|
||||
"""앞에 작은 절토 봉우리(+10㎥) · 뒤에 큰 성토(−1,000㎥).
|
||||
|
||||
진폭 2% 는 20㎥ 라 그림용 거르기에서는 봉우리가 지워진다."""
|
||||
nets = [0.0, 10.0, -10.0] + [-100.0] * 10
|
||||
points, cumulative = [], 0.0
|
||||
for index, net in enumerate(nets):
|
||||
cumulative += net
|
||||
points.append(
|
||||
{
|
||||
"station_id": f"S{index:03d}",
|
||||
"chainage_m": index * 20.0,
|
||||
"net_volume_m3": net,
|
||||
"cumulative_volume_m3": cumulative,
|
||||
"cut_soil_m3": max(net, 0.0),
|
||||
"cut_rock_m3": 0.0,
|
||||
"cut_rr_m3": 0.0,
|
||||
"cut_br_m3": 0.0,
|
||||
"cut_compacted_m3": max(net, 0.0),
|
||||
"fill_m3": max(-net, 0.0),
|
||||
"natural_spoil": False,
|
||||
"net_area_m2": net / 20.0,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"points": points,
|
||||
"cut_natural_m3": {"ea": 10.0, "rr": 0.0, "br": 0.0},
|
||||
"cut_compacted_m3": 10.0,
|
||||
"fill_compacted_m3": 1010.0,
|
||||
"final_cumulative_m3": cumulative,
|
||||
"surplus_m3": 0.0,
|
||||
"shortage_m3": -cumulative,
|
||||
"min_cumulative_m3": cumulative,
|
||||
"max_cumulative_m3": 10.0,
|
||||
"conversion": {"soil": 1.0, "ripping_rock": 1.0, "blasting_rock": 1.0},
|
||||
}
|
||||
|
||||
|
||||
def _run(tmp_path: Path, cases: list[dict]) -> list[dict | None]:
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(PROJECT_ROOT / "common_util" / "common_util_mass_haul_balance.ts"),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
for emitted in out.glob("*.js"):
|
||||
text = emitted.read_text(encoding="utf-8")
|
||||
emitted.write_text(
|
||||
re.sub(r'(from "\./[^"]+?)(")', lambda m: m.group(1) + ".js" + m.group(2), text),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
payload = tmp_path / "input.json"
|
||||
result = tmp_path / "output.json"
|
||||
payload.write_text(json.dumps({"result": _bump_then_fill(), "cases": cases}), encoding="utf-8")
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(payload), str(result)],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_수량_계획은_작은_봉우리도_운반으로_센다(tmp_path: Path) -> None:
|
||||
quantity, drawing = _run(tmp_path, [{}, {"drawing": True}])
|
||||
assert quantity is not None and drawing is not None
|
||||
# ① 기본(수량) — 봉우리 10㎥ 가 뒤 성토로 옮겨진다.
|
||||
assert quantity["hauled_m3"] == pytest.approx(10.0)
|
||||
# ② 그림 — 종전대로 걸러져 블록이 없다.
|
||||
assert drawing["blocks"] == 0
|
||||
assert drawing["hauled_m3"] == pytest.approx(0.0)
|
||||
# 토취(순 부족분)는 거르기와 무관하게 같다 — 운반만 사라졌던 것이다.
|
||||
assert quantity["borrow_m3"] == pytest.approx(drawing["borrow_m3"])
|
||||
|
||||
|
||||
def test_서버_정본은_수량과_그림을_따로_둔다() -> None:
|
||||
source = (PROJECT_ROOT / "B06_Section" / "B06_Section_Server_Calc_Node.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
# ③ 화면 선반입(그림)은 거른 계획, 저장 정본은 둘 다.
|
||||
assert "drawing: true" in source
|
||||
assert "haul_plan_drawing" in source
|
||||
|
||||
|
||||
def test_토적도는_그림용_계획을_먼저_그린다() -> None:
|
||||
def band(volume: float) -> dict:
|
||||
return {
|
||||
"index": 1,
|
||||
"equipment": "free_haul",
|
||||
"volume_m3": volume,
|
||||
"haul_distance_m": 10.0,
|
||||
"ea_m3": volume,
|
||||
"rr_m3": 0.0,
|
||||
"br_m3": 0.0,
|
||||
"level_base_m3": 0.0,
|
||||
"level_apex_m3": volume,
|
||||
"boundary_from_m": 0.0,
|
||||
"boundary_to_m": 40.0,
|
||||
"haul_from_m": 10.0,
|
||||
"haul_to_m": 30.0,
|
||||
}
|
||||
|
||||
def plan(volume: float) -> dict:
|
||||
block = {
|
||||
"index": 1,
|
||||
"from_m": 0.0,
|
||||
"to_m": 40.0,
|
||||
"base_m3": 0.0,
|
||||
"volume_m3": volume,
|
||||
"direction": "forward",
|
||||
"bands": [band(volume)],
|
||||
}
|
||||
return {"blocks": [block], "residuals": [], "transfers": []}
|
||||
|
||||
longitudinal = {"stations": [{"chainage_m": x, "station_id": f"s{x}"} for x in (0, 20, 40)]}
|
||||
points = [
|
||||
{"station_id": "s0", "chainage_m": 0.0, "cumulative_volume_m3": 0.0},
|
||||
{"station_id": "s20", "chainage_m": 20.0, "cumulative_volume_m3": 700.0},
|
||||
{"station_id": "s40", "chainage_m": 40.0, "cumulative_volume_m3": 0.0},
|
||||
]
|
||||
mass_haul = {"points": points, "haul_plan": plan(111.0), "haul_plan_drawing": plan(222.0)}
|
||||
|
||||
def labels(drawing: dict) -> list[str]:
|
||||
out: list[str] = []
|
||||
|
||||
def walk(entity: dict) -> None:
|
||||
if entity.get("type") == "Text":
|
||||
out.append(entity["shapeData"]["label"])
|
||||
for child in entity.get("children") or []:
|
||||
walk(child)
|
||||
|
||||
for entity in drawing["entities"]:
|
||||
walk(entity)
|
||||
return out
|
||||
|
||||
drawn = labels(build_mass_haul_drawing(longitudinal, mass_haul, "mass_haul"))
|
||||
assert "Q= 222.00M3" in drawn and "Q= 111.00M3" not in drawn
|
||||
# 옛 저장분(그림용 없음)은 종전대로 `haul_plan` 을 그린다.
|
||||
del mass_haul["haul_plan_drawing"]
|
||||
legacy = labels(build_mass_haul_drawing(longitudinal, mass_haul, "mass_haul"))
|
||||
assert "Q= 111.00M3" in legacy
|
||||
Reference in New Issue
Block a user