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

This commit is contained in:
2026-09-08 01:49:44 +09:00
19 changed files with 1879 additions and 116 deletions
@@ -72,7 +72,13 @@ COEFFICIENT_MARKS = ("손료계수", "시간당손료계수", "기계손료")
# 기계 시공능력 공식(Q = 3600·qo·K·f·E / Cm)의 파라미터 기호. 이 기호만 있는 표는 계수표다. # 기계 시공능력 공식(Q = 3600·qo·K·f·E / Cm)의 파라미터 기호. 이 기호만 있는 표는 계수표다.
COEFFICIENT_ROW_KEYS = {"K", "f", "E", "Cm", "㎝(sec)", "q", "qo", "Q", "V", "L"} COEFFICIENT_ROW_KEYS = {"K", "f", "E", "Cm", "㎝(sec)", "q", "qo", "Q", "V", "L"}
# 품셈 1장(적용기준)·할증·공제·단위 표준 — 공종이 아니라 **기준표**다. # 품셈 1장(적용기준)·할증·공제·단위 표준 — 공종이 아니라 **기준표**다.
REFERENCE_MARKS = ("할증률", "할인", "공제율", "적재량", "단 위", "지위") # ⚠ **「단 위」를 뺐다** (2026-09-07). 원래 겨냥은 품셈 1-2-2 「단위 표준」 표였는데,
# **1장은 chapter 규칙이 이미 참조로 거르므로** 이 표지는 넓기만 했다. 그 탓에
# **41건이 「헤더 '단 위'」 하나로 참조가 되어 버려지고 있었고**, 그 안에
# **돌쌓기(장비) 13-4-5·13-4-2**(우리 매핑이 실제로 쓰는 코드) · 12-2 표면 마무리 ·
# 9-19-1 면고르기 · 9-20 뿌리다듬기 · 13-8 막돌쌓기 같은 **명백한 공종**이 섞여 있었다.
# 「단위 열이 있다」는 소요량표의 흔한 모양이지 참조표의 표지가 아니다.
REFERENCE_MARKS = ("할증률", "할인", "공제율", "적재량", "지위")
# 값 대신 「어디를 따르라」고만 적은 표. 품이 아니므로 공종으로 세우지 않는다. # 값 대신 「어디를 따르라」고만 적은 표. 품이 아니므로 공종으로 세우지 않는다.
REFERENCE_CELL_MARKS = ("별도계상", "구역화물", "적용한다", "따른다", "준용") REFERENCE_CELL_MARKS = ("별도계상", "구역화물", "적용한다", "따른다", "준용")
# 직종이 값의 주인인 표 = 소요량형. `보통인부(인)`·`콘크리트공(인)` 처럼 `(인)` 이 붙는다. # 직종이 값의 주인인 표 = 소요량형. `보통인부(인)`·`콘크리트공(인)` 처럼 `(인)` 이 붙는다.
@@ -314,6 +320,55 @@ SHARE_TAG_RE = re.compile(
CAPACITY_SYMBOLS = {"K", "k", "f", "E", "Cm", "㎝(sec)", "q", "qo", "Q"} CAPACITY_SYMBOLS = {"K", "k", "f", "E", "Cm", "㎝(sec)", "q", "qo", "Q"}
# ⚠ **값 자리에 숫자가 아니라 식이 적힌 칸** (2026-09-07 서브 창 제보로 확인).
# `0.2 × 30%`(기초잡석 소할) · `(0.2+0.26)/2` · `1/1.3` 처럼 계산이 그대로 적혀 있다.
# 형태 판정은 통과하는데 **값이 숫자로 안 읽혀 그 성분이 조용히 빠진다** —
# `partial_ratio` 와 같은 병인데 배분율 딱지가 없어 아무 검사에도 안 걸렸다.
# ⚠ 여기서 **식을 계산하지 않는다.** 뜻을 잘못 읽으면 조용히 틀리므로 **드러내기만** 한다.
_PLAIN_NUMBER_RE = re.compile(r"^[\d,]+(?:\.\d+)?$")
_CALC_CELL_RE = re.compile(r"^[\d,.\s()×xX*/÷+\-%]+$")
_CALC_MAX_LEN = 22
def expression_cells(table: dict[str, Any]) -> list[str]:
"""값 자리에 식이 적힌 칸 목록. 없으면 빈 목록."""
found: list[str] = []
for row in table.get("rows", []):
for cell in row:
text = norm(cell)
if not text or len(text) > _CALC_MAX_LEN or _PLAIN_NUMBER_RE.match(text):
continue
if (
_CALC_CELL_RE.match(text)
and re.search(r"\d", text)
and re.search(r"[×xX*/÷+]", text)
):
found.append(text)
# 순서를 지키되 중복은 지운다 — 같은 식이 여러 줄에 반복된다.
seen: list[str] = []
for item in found:
if item not in seen:
seen.append(item)
return seen
# ⚠ **작업조 표** — 「형틀목공 4인 + 보통인부 1인 / 시공량 25·35·40㎡」처럼
# **인원과 시공량**으로 적힌 표다. 그 「4」는 소요량이 아니라 **작업조 인원**이라
# 행-자원으로 그냥 읽으면 **35배 부푼다**(유로폼 12-38-3 이 그 표다).
# ⚠ 값을 바꾸지 않고 **표시만** 한다 — 나누는 것은 받는 쪽 몫이다.
CREW_QUANTITY_MARKS = ("시공량", "작업량", "1일작업량")
def crew_table(table: dict[str, Any]) -> bool:
"""작업조 + 시공량으로 적힌 표인가."""
hay = " ".join(norm(h) for h in table.get("headers", []))
body = " ".join(norm(c) for row in table.get("rows", []) for c in row)
squeezed = (hay + " " + body).replace(" ", "")
if not any(mark in squeezed for mark in CREW_QUANTITY_MARKS):
return False
return bool(OCCUPATION_RE.search(squeezed))
def resource_shares(table: dict[str, Any]) -> dict[str, float]: def resource_shares(table: dict[str, Any]) -> dict[str, float]:
"""`{인력: 10.0, 장비: 90.0}` — 딱지에 붙은 몫. 없으면 빈 칸.""" """`{인력: 10.0, 장비: 90.0}` — 딱지에 붙은 몫. 없으면 빈 칸."""
shares: dict[str, float] = {} shares: dict[str, float] = {}
@@ -421,6 +476,10 @@ def build() -> dict[str, Any]:
"basis_source": basis_source, "basis_source": basis_source,
"resource_shares": shares, "resource_shares": shares,
"partial_ratio": partial, "partial_ratio": partial,
# ⚠ 값 자리에 식이 적힌 칸 — 그 성분은 숫자로 안 읽히므로 **금액을 만들면 안 된다**.
"expression_cells": expression_cells(table),
# ⚠ 작업조 표 — 「4」가 소요량이 아니라 인원이다. 그냥 읽으면 35배 부푼다.
"crew_table": crew_table(table),
# 공식 기호가 이 표에 직접 있는가 — 없으면 앞 표에서 물려받는 모양이다. # 공식 기호가 이 표에 직접 있는가 — 없으면 앞 표에서 물려받는 모양이다.
"capacity_formula_here": capacity_formula_pending(table), "capacity_formula_here": capacity_formula_pending(table),
"variant_key": variant_axis(table), "variant_key": variant_axis(table),
@@ -51,6 +51,8 @@ class FormworkTable:
reuse_by_class: list[dict[str, Any]] = field(default_factory=list) reuse_by_class: list[dict[str, Any]] = field(default_factory=list)
reuse_ratio_pct: dict[str, Any] = field(default_factory=dict) reuse_ratio_pct: dict[str, Any] = field(default_factory=dict)
shoring: dict[str, Any] = field(default_factory=dict) shoring: dict[str, Any] = field(default_factory=dict)
#: 유로폼 설치·해체 유형 — 품셈 12-38-3 [주]④ 가 시설 예시로 갈라 둔 것.
euroform_type: dict[str, Any] = field(default_factory=dict)
def for_type(self, type_id: str) -> dict[str, Any] | None: def for_type(self, type_id: str) -> dict[str, Any] | None:
for row in self.type_map: for row in self.type_map:
@@ -72,6 +74,7 @@ def load_formwork_table(path: Path | None = None) -> FormworkTable:
reuse_by_class=list(payload.get("reuse_by_class") or []), reuse_by_class=list(payload.get("reuse_by_class") or []),
reuse_ratio_pct=payload.get("reuse_ratio_pct") or {}, reuse_ratio_pct=payload.get("reuse_ratio_pct") or {},
shoring=payload.get("shoring") or {}, shoring=payload.get("shoring") or {},
euroform_type=payload.get("euroform_type") or {},
) )
+156 -3
View File
@@ -50,6 +50,11 @@ from common_util.common_util_quantity_spread import spread_by_unit
DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_work_item_mapping" DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_work_item_mapping"
DATASET_PREFIX = "work_item_mapping_" DATASET_PREFIX = "work_item_mapping_"
#: 철근 갈래표 — **품셈 12-3 [주]① 원문**이 구조물 예시로 갈라 둔 것이라 사람이 고르는 값이
#: 아니다(거푸집 사용횟수 1-7-1 과 같은 자리). 원문 예시에 안 걸리면 지어내지 않는다.
REBAR_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_rebar"
REBAR_PREFIX = "rebar_complexity_"
#: 줄이 어디서 왔나 — 되짚을 때 쓴다. #: 줄이 어디서 왔나 — 되짚을 때 쓴다.
ORIGIN_EARTHWORK = "earthwork" ORIGIN_EARTHWORK = "earthwork"
ORIGIN_STRUCTURE = "structure" ORIGIN_STRUCTURE = "structure"
@@ -92,6 +97,7 @@ class WorkItemMapping:
pending_user: dict[str, Any] = field(default_factory=dict) pending_user: dict[str, Any] = field(default_factory=dict)
composite: dict[str, Any] = field(default_factory=dict) composite: dict[str, Any] = field(default_factory=dict)
concrete_placing: dict[str, Any] = field(default_factory=dict) concrete_placing: dict[str, Any] = field(default_factory=dict)
unit_conversion: dict[str, Any] = field(default_factory=dict)
def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401 def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401
"""공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다.""" """공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다."""
@@ -143,9 +149,96 @@ def load_mapping(path: Path | None = None) -> WorkItemMapping:
pending_user=payload.get("pending_user") or {}, pending_user=payload.get("pending_user") or {},
composite=payload.get("composite") or {}, composite=payload.get("composite") or {},
concrete_placing=payload.get("concrete_placing") or {}, concrete_placing=payload.get("concrete_placing") or {},
unit_conversion=(payload.get("composite") or {}).get("unit_conversion") or {},
) )
def composite_quantities(
structure: dict[str, Any],
composite: dict[str, Any],
mapping: WorkItemMapping,
) -> tuple[list[dict[str, Any]], list[str]]:
"""묶음 조각마다 **부위별 수량**을 채운다. (조각 목록, 못 채운 사유).
⚠ 조각은 **원단위 성분 이름으로** 찾는다. 이름이 어긋나면 물량이 조용히 0 이 되므로
못 찾으면 그 조각을 `not_ready` 로 남기고 사유를 적는다 — 0 을 적지 않는다.
⚠ **단위를 반드시 맞춘다.** 철근 단가는 `원/ton` 인데 원단위는 `㎏` 이다.
안 맞추면 **1000배 틀린다** — 밑수에서 겪은 것과 같은 자리다.
"""
# ⚠ 원단위 자체가 없으면 조각을 늘어놓지 않는다 — 같은 사유가 다섯 번 반복되면
# **진짜 사유가 묻힌다**(화면에서 실제로 그렇게 보였다). 한 줄로 말한다.
if not (structure.get("components") or []):
note = "; ".join(structure.get("notes") or []) or "구조물 원단위가 없음"
return [], [{"code": None, "reason": note}]
amounts: dict[str, tuple[float, str]] = {}
for component in structure.get("components") or []:
name = str(component.get("name") or "").strip()
amounts[name] = (float(component.get("amount") or 0.0), str(component.get("unit") or ""))
kg_to_ton = float((mapping.unit_conversion or {}).get("kg_to_ton") or 0.001)
parts: list[dict[str, Any]] = []
missing: list[str] = []
for spec in composite.get("parts") or []:
if not isinstance(spec, dict): # 옛 모양(코드 문자열)은 그대로 흘린다
parts.append({"code": str(spec)})
continue
sources = list(spec.get("from_components") or [])
found = [name for name in sources if name in amounts]
total = sum(amounts[name][0] for name in found)
if spec.get("unit_from") == "kg" and spec.get("unit") == "ton":
total *= kg_to_ton
kinds = {
component.get("basis_kind")
for component in structure.get("components") or []
if str(component.get("name") or "").strip() in found
}
suffix = spec.get("kind_suffix")
entry: dict[str, Any] = {
"code": spec.get("code"),
"name": spec.get("name"),
"unit": spec.get("unit"),
"quantity": total if found else None,
# 조각마다 근거를 단다 — 치수 전개와 관측값이 한 묶음에 섞인다.
"basis_kind": next(iter(kinds)) if len(kinds) == 1 else (sorted(kinds) or None),
"from_components": sources,
}
if spec.get("incomplete_note"):
# ⚠ 물량은 섰으나 **일부 몫이 빠진** 조각 — 「못 채움」과 달리 값은 있다.
# 화면·인계 둘 다 그 사실을 알아야 「다 섰다」로 오해하지 않는다.
entry["incomplete_note"] = spec["incomplete_note"]
if suffix == "euroform_type":
kind, why = euroform_type(str(structure.get("type_id") or ""))
entry["kind"] = kind
entry["kind_basis"] = why
if kind:
entry["code"] = f"{spec.get('code')}#{kind}"
else:
entry["not_ready"] = True
entry["why"] = why
missing.append({"code": spec.get("code"), "reason": why})
if suffix == "rebar_complexity":
# 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다.
complexity, why = rebar_complexity(
str(structure.get("type_id") or ""), structure.get("options") or {}
)
entry["kind"] = complexity
entry["kind_basis"] = why
if complexity:
entry["code"] = f"{spec.get('code')}#{complexity}"
else:
entry["not_ready"] = True
entry["why"] = why
missing.append({"code": spec.get("code"), "reason": why})
if spec.get("not_ready") or not found:
entry["not_ready"] = True
entry["why"] = str(spec.get("why") or "원단위에 해당 성분이 없음")
# ⚠ 「단가 없음」과 「물량 없음」을 받는 쪽이 갈라야 하므로 **구조로** 낸다.
missing.append({"code": spec.get("code"), "reason": entry["why"]})
parts.append(entry)
return parts, missing
def structure_kind(structure: dict[str, Any]) -> str: def structure_kind(structure: dict[str, Any]) -> str:
"""콘크리트 구조물 종류 — **원단위에 철근이 있나 없나로 판정한다.** """콘크리트 구조물 종류 — **원단위에 철근이 있나 없나로 판정한다.**
@@ -169,6 +262,62 @@ def placing_code(mapping: WorkItemMapping, method: str | None) -> tuple[str | No
return codes.get(default), True return codes.get(default), True
def load_rebar_table(path: Path | None = None) -> dict[str, Any]:
"""철근 갈래표를 읽는다. 파일이 없으면 **빈 표** — 전부 「갈래 미확보」로 드러난다."""
target = path
if target is None:
files = sorted(REBAR_DIR.glob(REBAR_PREFIX + "*.json")) if REBAR_DIR.is_dir() else []
target = files[-1] if files else None
if target is None or not target.is_file():
return {}
return json.loads(target.read_text(encoding="utf-8"))
def rebar_complexity(
type_id: str, options: dict[str, Any], table: dict[str, Any] | None = None
) -> tuple[str | None, str]:
"""(철근 갈래, 근거). **원문 예시에 걸리는 것만** 정하고 안 걸리면 `(None, 사유)`.
품셈 12-3 [주]① — 「간단: 측구·간단한 기초·**중력식 옹벽** / 보통: 수문·**반중력식 옹벽**·
교대 / 복잡: 교량 슬래브·암거·우물통·**부벽식 옹벽** / 매우복잡: 구주식 교대·교각…」.
사람에게 묻지 않는다 — **판정할 수 있는 것을 물으면 그것이 곧 미결이 된다.**
"""
found = table if table is not None else load_rebar_table()
form = options.get("form")
fallback: dict[str, Any] | None = None
for row in found.get("form_map") or []:
if row.get("type_id") != type_id:
continue
if "form" not in row:
fallback = row
continue
if row.get("form") == form:
if row.get("class"):
return str(row["class"]), f"품셈 12-3 [주]① 「{row.get('matched')}"
return None, str(row.get("why") or "원문 예시에 없음")
if fallback and fallback.get("class"):
return str(fallback["class"]), f"품셈 12-3 [주]① 「{fallback.get('matched')}"
return None, f"품셈 12-3 [주]① 예시에 없는 구조({type_id} {form}) — 임의로 고르지 않음"
def euroform_type(type_id: str, table: dict[str, Any] | None = None) -> tuple[str | None, str]:
"""유로폼 설치·해체 유형 — **품셈 12-38-3 [주]④ 원문**이 시설 예시로 갈라 둔다.
「보통: 측구, 수로, **옹벽**, 일반적인 벽체, 박스」. 거푸집 사용횟수(1-7-1)·철근 갈래
(12-3 [주]①)에 이어 **네 번째** 같은 자리다 — 미결로 올리기 전에 원문부터 뒤진다.
⚠ 12-38-1 「사용횟수」와 헷갈리지 말 것 — 그쪽은 유로폼(강재)의 **잔존율**이고
1-7-1 의 소모성 거푸집 전용 횟수와도 다른 자리다.
"""
from B08_Quantity.B08_Quantity_Engine_Formwork import load_formwork_table
found = table if table is not None else load_formwork_table().euroform_type
for row in (found or {}).get("type_map") or []:
if row.get("type_id") == type_id and row.get("class"):
return str(row["class"]), f"품셈 12-38-3 [주]④ 「{row.get('matched')}"
return None, f"품셈 12-38-3 [주]④ 예시에 없는 시설({type_id}) — 임의로 고르지 않음"
def _spec_detail(structure: dict[str, Any]) -> str: def _spec_detail(structure: dict[str, Any]) -> str:
"""규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다.""" """규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다."""
parts: list[str] = [] parts: list[str] = []
@@ -307,6 +456,10 @@ def _structure_rows(
code = entry.get("work_item_code") code = entry.get("work_item_code")
composite = mapping.composite_for(type_id) if code is None else None composite = mapping.composite_for(type_id) if code is None else None
kind = structure_kind(structure) if composite else None kind = structure_kind(structure) if composite else None
parts: list[dict[str, Any]] | None = None
parts_missing: list[str] = []
if composite:
parts, parts_missing = composite_quantities(structure, composite, mapping)
if code is None and composite is None: if code is None and composite is None:
unmatched.append(f"구조물({type_id})") unmatched.append(f"구조물({type_id})")
length = float(structure.get("length_m") or 0.0) length = float(structure.get("length_m") or 0.0)
@@ -328,11 +481,11 @@ def _structure_rows(
"station_to": structure.get("end_m"), "station_to": structure.get("end_m"),
"spec_detail": _spec_detail(structure), "spec_detail": _spec_detail(structure),
# 품셈에 그 이름의 공종이 없어 여러 공종을 묶는 자리 — 빈 코드와 구별한다. # 품셈에 그 이름의 공종이 없어 여러 공종을 묶는 자리 — 빈 코드와 구별한다.
"composite_parts": (composite or {}).get("parts"), "composite_parts": parts,
# 철근이 있나 없나로 자동 판정 — 사람이 고르는 값이 아니다. # 철근이 있나 없나로 자동 판정 — 사람이 고르는 값이 아니다.
"structure_kind": kind, "structure_kind": kind,
# ⚠ 아직 일위대가가 안 선 공종 — 지금 세우면 절반짜리가 된다. # ⚠ 물량을 못 채운 조각 — 0 으로 적지 않고 사유와 함께 드러낸다.
"composite_not_ready": (composite or {}).get("not_ready"), "composite_not_ready": parts_missing or None,
"in_bill": True, "in_bill": True,
"in_bill_reason": (composite or {}).get("why", ""), "in_bill_reason": (composite or {}).get("why", ""),
"origin": ORIGIN_STRUCTURE, "origin": ORIGIN_STRUCTURE,
@@ -55,7 +55,11 @@ def preparation_rows(slope_totals: dict[str, float] | None = None) -> list[dict[
"status": STATUS_COUNTED_ELSEWHERE, "status": STATUS_COUNTED_ELSEWHERE,
# ⚠ 값을 여기서 또 내면 같은 나무를 두 번 벤다. 참고로 면적만 보인다. # ⚠ 값을 여기서 또 내면 같은 나무를 두 번 벤다. 참고로 면적만 보인다.
"reference_amount": tree_area, "reference_amount": tree_area,
"reason": "토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상", "reason": (
"토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상. "
"⚠ 다만 **공종 미확정** — 품셈 4장이 벌목을 목적별로 갈라(수확베기·단목베기·"
"위험목 베기) 임도 지장목이 어디에 붙는지 원본이 말하지 않음. 지금은 공종코드 없이 감."
),
"work_item_code": None, "work_item_code": None,
}, },
{ {
@@ -127,6 +127,9 @@ class StructureQuantity:
# 측점 — 내역 줄에 「어디부터 어디까지」를 적으려면 여기서 따라가야 한다(B09 인계). # 측점 — 내역 줄에 「어디부터 어디까지」를 적으려면 여기서 따라가야 한다(B09 인계).
start_m: float | None = None start_m: float | None = None
end_m: float | None = None end_m: float | None = None
# 저장된 제원 — **형식(반중력식…)처럼 뒤 단계가 봐야 하는 값**이 여기 있다.
# 치수를 다시 쓰라는 뜻이 아니라 **읽으라고** 실어 나른다(치수 정본은 여전히 하나).
options: dict[str, Any] = field(default_factory=dict)
components: list[Component] = field(default_factory=list) components: list[Component] = field(default_factory=list)
notes: list[str] = field(default_factory=list) notes: list[str] = field(default_factory=list)
@@ -238,7 +241,10 @@ def stone_masonry(
"m", "m",
masonry_area / constants["weep_hole_area_m2"] * constants["weep_hole_length_m"], masonry_area / constants["weep_hole_area_m2"] * constants["weep_hole_length_m"],
DESTINATION["물구멍"], DESTINATION["물구멍"],
"돌쌓기 ÷ 2㎡/개소 × 0.5 m/개소 (관 규격 미확정)", # ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 —
# **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식).
"돌쌓기 ÷ 2㎡/개소 × 0.5 m/개소 · ⚠ 잠정: 관 Ø 미정(법 3~6㎝ / 실무 Ø50) ·"
" 간격 2.0㎡당 1개소(법 2~3㎡당 1개소 이상)",
) )
) )
@@ -350,6 +356,7 @@ def expand(
height_m=height, height_m=height,
start_m=start if structure.get("start_m") is not None else None, start_m=start if structure.get("start_m") is not None else None,
end_m=end if structure.get("end_m") is not None else None, end_m=end if structure.get("end_m") is not None else None,
options=dict(options),
) )
expander = EXPANDERS.get(type_id) expander = EXPANDERS.get(type_id)
if expander is None: if expander is None:
@@ -417,6 +424,8 @@ def build_table(
"height_m": item.height_m, "height_m": item.height_m,
"start_m": item.start_m, "start_m": item.start_m,
"end_m": item.end_m, "end_m": item.end_m,
# 저장된 제원 — 형식(반중력식…)처럼 **뒤 단계가 읽어야 하는** 값이 여기 있다.
"options": item.options,
"notes": item.notes, "notes": item.notes,
"components": [ "components": [
{ {
+23 -1
View File
@@ -31,13 +31,16 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table
from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table
from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping
from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_preparation_table from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_preparation_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import summary_input_rows from B08_Quantity.B08_Quantity_Engine_HaulSummary import summary_input_rows
from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table
from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes
from common_util.common_util_project_settings import ( from common_util.common_util_project_settings import (
CONCRETE_PLACING_METHODS,
ROCK_METHODS, ROCK_METHODS,
application_ratio, application_ratio,
concrete_placing_method,
quantity_settings, quantity_settings,
rock_classes, rock_classes,
save_section, save_section,
@@ -99,6 +102,16 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
table["preparation"] = build_preparation_table( table["preparation"] = build_preparation_table(
slope.get("totals") or {}, await _route_structures(project_id) slope.get("totals") or {}, await _route_structures(project_id)
) )
method, method_is_default = concrete_placing_method(settings)
# ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다.
table["concrete_placing"] = {
"method": method,
"is_default": method_is_default,
# ⚠ **표시 전용 참고값** — 「무엇을 정해야 하는지」만으로는 부족하고
# 「정하면 얼마나 달라지는지」가 보여야 사용자가 판단한다(2026-09-07 조율 창).
# B08 의 어떤 계산에도 안 들어간다.
"price_hint": (load_mapping().concrete_placing or {}).get("price_hint_krw_per_m3"),
}
table["settings"] = settings table["settings"] = settings
table["project_root_known"] = project_root is not None table["project_root_known"] = project_root is not None
table["route_id"] = route_id table["route_id"] = route_id
@@ -164,6 +177,8 @@ class QuantitySettingsBody(BaseModel):
# 자재별 관급/사급 — `{자재명: {"supply": …, "install_by": …}}`. # 자재별 관급/사급 — `{자재명: {"supply": …, "install_by": …}}`.
# 표 안에서 줄마다 고른 값이 여기로 온다(2026-09-07 확정). # 표 안에서 줄마다 고른 값이 여기로 온다(2026-09-07 확정).
material_supply: dict[str, Any] | None = None material_supply: dict[str, Any] | None = None
# 콘크리트 타설 방식. `""` 는 「안 정함」으로 되돌리는 뜻이라 서버가 None 으로 만든다.
concrete_placing_method: str | None = None
@router.put("/{project_id}/quantity/settings") @router.put("/{project_id}/quantity/settings")
@@ -183,6 +198,10 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
) )
values = {key: value for key, value in body.model_dump().items() if value is not None} values = {key: value for key, value in body.model_dump().items() if value is not None}
if "concrete_placing_method" in values:
method = values["concrete_placing_method"]
# 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리).
values["concrete_placing_method"] = method if method in CONCRETE_PLACING_METHODS else None
if "rock_methods" in values: if "rock_methods" in values:
# 「안 정함」(빈 값)은 저장하지 않는다 — 정한 것과 구별이 안 된다. 통째로 갈아 끼우므로 # 「안 정함」(빈 값)은 저장하지 않는다 — 정한 것과 구별이 안 된다. 통째로 갈아 끼우므로
# 여기서 버리면 그 갈래는 미지정으로 돌아간다. # 여기서 버리면 그 갈래는 미지정으로 돌아간다.
@@ -195,7 +214,10 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
# ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면 # ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면
# 「안 정함」으로 되돌아가지 않는다(2026-09-07 화면에서 걸린 자리). # 「안 정함」으로 되돌아가지 않는다(2026-09-07 화면에서 걸린 자리).
saved = await asyncio.to_thread( saved = await asyncio.to_thread(
_save_quantity, root, values, ("rock_methods", "material_supply") _save_quantity,
root,
values,
("rock_methods", "material_supply", "concrete_placing_method"),
) )
except Exception: except Exception:
logger.exception("B08 설정 저장 실패(쓰기): project_id=%s", project_id) logger.exception("B08 설정 저장 실패(쓰기): project_id=%s", project_id)
@@ -100,10 +100,23 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
unit_table, unit_table,
supply_map=settings.get("material_supply") or {}, supply_map=settings.get("material_supply") or {},
) )
# 묶음으로 서는 구조물의 조각을 화면에도 보인다 — 코드만으로는 사람이 검증 못 한다.
handoff = build_handoff(unit_quantity_table=unit_table)
composite = [
{
"name": row["name"],
"parts": row["composite_parts"],
"not_ready": row.get("composite_not_ready"),
}
for row in handoff["work_items"]
# 조각이 없어도(원단위 자체가 없어 못 세운 경우) 사유는 보여야 한다.
if row.get("composite_parts") or row.get("composite_not_ready")
]
return JSONResponse( return JSONResponse(
content={ content={
"unit_quantity": unit_table, "unit_quantity": unit_table,
"material": material_table, "material": material_table,
"composite": composite,
"skipped_structures": skipped, "skipped_structures": skipped,
"structure_count": len(structures), "structure_count": len(structures),
} }
@@ -63,6 +63,8 @@ export interface QuantitySettings {
rock_methods?: Record<string, string>; rock_methods?: Record<string, string>;
/** 자재별 관급/사급 — 표 안에서 줄마다 고른 값. */ /** 자재별 관급/사급 — 표 안에서 줄마다 고른 값. */
material_supply?: Record<string, { supply: string; install_by: string | null }>; material_supply?: Record<string, { supply: string; install_by: string | null }>;
/** 콘크리트 타설 방식. `null`·없음이면 **아직 안 정한 것**이고 화면이 기본값 안내를 띄운다. */
concrete_placing_method?: string | null;
} }
export interface EarthworkTable { export interface EarthworkTable {
@@ -32,6 +32,18 @@ const CSS = `
box-shadow: inset 2px 0 0 var(--color-accent, #6c8ebf); box-shadow: inset 2px 0 0 var(--color-accent, #6c8ebf);
} }
/* 잠정값 안내 — 금액에 걸리는 값이 조용히 기본으로 돌지 않게. */
.b08-quantity__notice {
margin: 0.35rem 0 0;
padding: 0.4rem 0.5rem;
font-size: 0.85em;
line-height: 1.4;
color: var(--color-text);
background: var(--color-surface-raised);
border-left: 3px solid var(--color-warning, #c08a3e);
border-radius: 3px;
}
.b08-grid__caption { .b08-grid__caption {
margin: 0; margin: 0;
font-size: 12px; font-size: 12px;
@@ -61,6 +61,22 @@ export interface UnitQuantityStructure {
}[]; }[];
} }
/** 묶음으로 서는 구조물의 조각. 품셈에 그 이름의 공종이 없어 여러 공종으로 나뉜다. */
export interface CompositePart {
code: string | null;
name?: string;
unit?: string;
quantity: number | null;
basis_kind?: string | string[] | null;
/** 철근 갈래(간단/보통/복잡/매우복잡) — 품셈 원문이 정한다. */
kind?: string | null;
kind_basis?: string;
not_ready?: boolean;
why?: string;
/** 물량은 섰으나 일부 몫이 빠진 조각 — 「다 섰다」로 오해하지 않게 함께 보인다. */
incomplete_note?: string;
}
export interface MaterialResponse { export interface MaterialResponse {
unit_quantity: { unit_quantity: {
structures: UnitQuantityStructure[]; structures: UnitQuantityStructure[];
@@ -72,6 +88,13 @@ export interface MaterialResponse {
material: MaterialTable; material: MaterialTable;
skipped_structures: string[]; skipped_structures: string[];
structure_count: number; structure_count: number;
/** 인계에서 온 묶음 조각 — 화면이 「무엇으로 나뉘어 서는지」를 보인다. */
composite?: {
name: string;
parts: CompositePart[];
/** 못 채운 조각 — 「단가 없음」과 「물량 없음」을 가르려고 사유를 구조로 받는다. */
not_ready?: { code: string | null; reason: string }[] | null;
}[];
} }
/** 거푸집·동바리 안내에 쓰는 값. */ /** 거푸집·동바리 안내에 쓰는 값. */
@@ -115,6 +138,11 @@ function headRow(labels: string[]): HTMLTableSectionElement {
return head; return head;
} }
/** 못 채운 조각의 사유를 사람이 읽는 줄로. 코드가 있으면 앞에 붙인다. */
function reasons(items?: { code: string | null; reason: string }[] | null): string[] {
return (items ?? []).map((item) => (item.code ? `${item.code} ${item.reason}` : item.reason));
}
/** 못 정한 값 안내 — 목록이 있을 때만 뜬다. 매번 뜨면 잡음이 된다. */ /** 못 정한 값 안내 — 목록이 있을 때만 뜬다. 매번 뜨면 잡음이 된다. */
function warning(title: string, items: string[]): HTMLElement | null { function warning(title: string, items: string[]): HTMLElement | null {
if (!items.length) return null; if (!items.length) return null;
@@ -321,6 +349,38 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement
wrap.append(line); wrap.append(line);
} }
// ⚠ 품셈에 그 이름의 공종이 없어 **여러 공종으로 나뉘어 서는** 구조물 — 무엇으로
// 나뉘는지와 각 조각의 물량·갈래를 보인다. 코드만으로는 사람이 검증할 수 없다.
for (const group of response.composite ?? []) {
// 조각이 하나도 없으면 「묶음 공종 — 」 빈 줄이 남는다. 사유만 보이는 것이 낫다.
if (!group.parts?.length) {
const blocked = warning("⚠ 묶음을 못 세움", reasons(group.not_ready));
if (blocked) wrap.append(blocked);
continue;
}
const box = document.createElement("p");
box.className = "b08-quantity__notice";
const parts = group.parts.map((part) => {
const kind = part.kind ? `#${part.kind}` : "";
const amount =
part.quantity === null || part.quantity === undefined
? ""
: `${num(part.quantity, 3)}${part.unit ?? ""}`;
const flag = part.not_ready ? " ⚠" : part.incomplete_note ? " ⚠부분" : "";
return `${part.name ?? part.code}${kind} ${amount}${flag}`;
});
box.textContent = `${group.name}: 묶음 공종 — ${parts.join(" · ")}`;
wrap.append(box);
const blocked = warning("⚠ 물량을 못 채운 조각", reasons(group.not_ready));
if (blocked) wrap.append(blocked);
// ⚠ 값이 있는데 일부만 선 조각 — **부분 성공이 완전 실패보다 위험하다**.
const partial = warning(
"⚠ 일부 몫이 빠진 조각",
group.parts.filter((p) => p.incomplete_note).map((p) => `${p.name}: ${p.incomplete_note}`),
);
if (partial) wrap.append(partial);
}
if (!unit.structures.length) { if (!unit.structures.length) {
const empty = document.createElement("p"); const empty = document.createElement("p");
empty.className = "b08-quantity__message"; empty.className = "b08-quantity__message";
+69
View File
@@ -83,6 +83,7 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
// 병합해 옛 값이 남아 되돌릴 길이 없다(화면에서 걸린 자리). 빈 값은 서버가 버린다. // 병합해 옛 값이 남아 되돌릴 길이 없다(화면에서 걸린 자리). 빈 값은 서버가 버린다.
rock_methods: draft.rock_methods, rock_methods: draft.rock_methods,
material_supply: draft.material_supply, material_supply: draft.material_supply,
concrete_placing_method: draft.concrete_placing_method,
}), }),
}, },
); );
@@ -133,6 +134,13 @@ function numberField(label: string, value: number, onInput: (value: number) => v
return row; return row;
} }
/** 타설 방식 표기 — 코드가 아니라 사람이 읽는 이름으로 보인다. */
const PLACING_LABELS: Record<string, string> = {
ready_mixed: "레디믹스트",
machine_mixed: "기계비빔",
hand_mixed: "인력비빔",
};
/** 고르는 칸. 첫 보기는 **「안 정함」**이고 그것이 기본이다 — 찍으면 값이 조용히 틀린다. */ /** 고르는 칸. 첫 보기는 **「안 정함」**이고 그것이 기본이다 — 찍으면 값이 조용히 틀린다. */
function selectField( function selectField(
label: string, label: string,
@@ -172,6 +180,8 @@ interface DraftSettings {
application_ratios_pct: Record<string, number>; application_ratios_pct: Record<string, number>;
// 갈래별 시공법 — `""` 는 「안 정함」이고 저장에서 빠진다. // 갈래별 시공법 — `""` 는 「안 정함」이고 저장에서 빠진다.
rock_methods: Record<string, string>; rock_methods: Record<string, string>;
// 콘크리트 타설 방식 — `""` 는 「안 정함」이고 저장에서 지워진다.
concrete_placing_method: string;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값. // 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
material_supply: Record<string, SupplyChoice>; material_supply: Record<string, SupplyChoice>;
dirty: boolean; dirty: boolean;
@@ -252,6 +262,64 @@ function buildQuantitySidePanel(
} }
} }
// ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ──
panel.append(field(L("B08_Quantity_Side_Placing"), ""));
panel.append(
selectField(
L("B08_Quantity_Side_Placing_Label"),
draft.concrete_placing_method,
[
{ value: "", label: L("B08_Quantity_Placing_Unset") },
{ value: "ready_mixed", label: L("B08_Quantity_Placing_Ready") },
{ value: "machine_mixed", label: L("B08_Quantity_Placing_Machine") },
{ value: "hand_mixed", label: L("B08_Quantity_Placing_Hand") },
],
(value) => {
draft.concrete_placing_method = value;
draft.dirty = true;
},
),
);
const placing = (
table as unknown as {
concrete_placing?: {
method: string;
is_default: boolean;
price_hint?: { basis?: string; values?: Record<string, number> };
};
}
).concrete_placing;
if (placing) {
// ⚠ 방식 이름을 **늘** 값 옆에 보인다 — 코드(`12-01-01`)만으로는 무엇을 쓰는지 모른다.
const label = PLACING_LABELS[placing.method] ?? placing.method;
panel.append(
field(
L("B08_Quantity_Placing_Current"),
placing.is_default ? `${label} (${L("B08_Quantity_Placing_Default_Tag")})` : label,
),
);
}
// ⚠ 「정하면 얼마나 달라지는지」까지 보여야 사용자가 판단한다. 이 값은 **참고 표시 전용**이고
// B08 의 어떤 계산에도 안 들어간다(금액은 B09 몫).
const hint = placing?.price_hint;
if (hint?.values) {
const line = document.createElement("p");
line.className = "b08-quantity__notice";
const parts = Object.entries(hint.values).map(
([key, value]) =>
`${PLACING_LABELS[key] ?? key} ${Math.round(value).toLocaleString("ko-KR")}`,
);
line.textContent = `${L("B08_Quantity_Placing_Hint")} ${hint.basis ?? ""}${parts.join(" · ")}`;
panel.append(line);
}
if (placing?.is_default) {
// 「확인 필요」만 있으면 무엇을 정해야 하는지 모른다 — **지금 무엇으로 돌고 있는지**를 함께 적는다.
const notice = document.createElement("p");
notice.className = "b08-quantity__notice";
notice.textContent = L("B08_Quantity_Placing_Default_Notice");
panel.append(notice);
}
const saveButton = createButton({ const saveButton = createButton({
label: L("B08_Quantity_Btn_Save"), label: L("B08_Quantity_Btn_Save"),
variant: "ghost", variant: "ghost",
@@ -431,6 +499,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
rock_ratios_pct: { ...(stored.rock_ratios_pct ?? {}) }, rock_ratios_pct: { ...(stored.rock_ratios_pct ?? {}) },
application_ratios_pct: { ...(stored.application_ratios_pct ?? {}) }, application_ratios_pct: { ...(stored.application_ratios_pct ?? {}) },
rock_methods: { ...((stored.rock_methods ?? {}) as Record<string, string>) }, rock_methods: { ...((stored.rock_methods ?? {}) as Record<string, string>) },
concrete_placing_method: (stored.concrete_placing_method as string) ?? "",
material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) }, material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) },
dirty: false, dirty: false,
}; };
+21 -3
View File
@@ -94,9 +94,10 @@ def default_settings() -> dict[str, Any]:
# 화면에 드러난다 — 사급으로 조용히 넘기면 관급자재대가 새 나간다. # 화면에 드러난다 — 사급으로 조용히 넘기면 관급자재대가 새 나간다.
"material_supply": {}, "material_supply": {},
# 콘크리트 타설 방식 — `ready_mixed`(FP-12-01-01) / `machine_mixed`(-02) / # 콘크리트 타설 방식 — `ready_mixed`(FP-12-01-01) / `machine_mixed`(-02) /
# `hand_mixed`(-03). **설계 판단**이라 사용자가 고르는 값이고, 기본은 실무가 # `hand_mixed`(-03). **설계 판단**이라 사용자가 고른다.
# 쓰는 레디믹스트로 둔다. ⚠ 잠정이며 사용자 확정 대기 항목이다. # ⚠ 기본은 `None` — 「안 정함」과 「일부러 레디믹스트를 고른 것」을 갈라야
"concrete_placing_method": "ready_mixed", # 화면이 「기본값 적용 중」을 정직하게 띄운다. 값을 미리 넣으면 그 구별이 사라진다.
"concrete_placing_method": None,
"dataset_versions": {}, "dataset_versions": {},
}, },
"estimation": { "estimation": {
@@ -189,6 +190,23 @@ def rock_classes(settings: dict[str, Any]) -> list[str]:
return list(ROCK_CLASS_SETS.get(name, ROCK_CLASS_SETS[DEFAULT_ROCK_CLASS_SET])) return list(ROCK_CLASS_SETS.get(name, ROCK_CLASS_SETS[DEFAULT_ROCK_CLASS_SET]))
#: 타설 방식 — 안 정했을 때 쓰는 값. **금액에 바로 걸리는 자리**라 화면이 잠정임을 띄운다.
CONCRETE_PLACING_METHODS = ("ready_mixed", "machine_mixed", "hand_mixed")
DEFAULT_CONCRETE_PLACING_METHOD = "ready_mixed"
def concrete_placing_method(settings: dict[str, Any]) -> tuple[str, bool]:
"""(타설 방식, 기본값을 쓰고 있는가).
둘째 값이 참이면 **사용자가 아직 정한 **이다 화면이 기본값 적용 띄워야
한다. 조용히 기본으로 돌면 사용자는 그것이 잠정인 줄도 모른다.
"""
stored = settings.get("concrete_placing_method")
if stored in CONCRETE_PLACING_METHODS:
return str(stored), False
return DEFAULT_CONCRETE_PLACING_METHOD, True
def application_ratio(settings: dict[str, Any], key: str) -> float: def application_ratio(settings: dict[str, Any], key: str) -> float:
"""반영률을 0~1 로. 없으면 100 %(=1.0) — 실무 관측치를 기본값으로 쓰지 않는다.""" """반영률을 0~1 로. 없으면 100 %(=1.0) — 실무 관측치를 기본값으로 쓰지 않는다."""
raw = (settings.get("application_ratios_pct") or {}).get(key, 100) raw = (settings.get("application_ratios_pct") or {}).get(key, 100)
@@ -17,22 +17,45 @@
{ {
"reuse_count": 2, "reuse_count": 2,
"class": "복잡한 구조", "class": "복잡한 구조",
"examples": ["T형보", "난간", "복잡한 교각", "교대", "수문관 본체"] "examples": [
"T형보",
"난간",
"복잡한 교각",
"교대",
"수문관 본체"
]
}, },
{ {
"reuse_count": 3, "reuse_count": 3,
"class": "약간 복잡한 구조", "class": "약간 복잡한 구조",
"examples": ["슬래브", "교대", "교각", "옹벽", "파라펫트", "날개벽"] "examples": [
"슬래브",
"교대",
"교각",
"옹벽",
"파라펫트",
"날개벽"
]
}, },
{ {
"reuse_count": 4, "reuse_count": 4,
"class": "비교적 간단한 구조", "class": "비교적 간단한 구조",
"examples": ["측구", "수로", "확대기초", "우물통"] "examples": [
"측구",
"수로",
"확대기초",
"우물통"
]
}, },
{ {
"reuse_count": 6, "reuse_count": 6,
"class": "극히 간단한 구조", "class": "극히 간단한 구조",
"examples": ["수문 기초", "관의 기초", "호안 기초", "보호공 기초"] "examples": [
"수문 기초",
"관의 기초",
"호안 기초",
"보호공 기초"
]
} }
], ],
"type_map": [ "type_map": [
@@ -57,12 +80,87 @@
"reuse_ratio_pct": { "reuse_ratio_pct": {
"note": "품셈 12-4 「사용횟수별 기준수량에 대한 비율(%)」. **B09 일위대가가 쓰는 값**이며 B08 은 참고로만 싣는다 — 여기서 곱하면 이중계상.", "note": "품셈 12-4 「사용횟수별 기준수량에 대한 비율(%)」. **B09 일위대가가 쓰는 값**이며 B08 은 참고로만 싣는다 — 여기서 곱하면 이중계상.",
"table_id": "F0336", "table_id": "F0336",
"plywood": { "1": 100.0, "2": 57.0, "3": 46.1, "4": 40.1, "5": 37.1, "6": 34.7 }, "plywood": {
"timber": { "1": 100.0, "2": 60.0, "3": 47.1, "4": 40.0, "5": 34.2, "6": 32.0 } "1": 100.0,
"2": 57.0,
"3": 46.1,
"4": 40.1,
"5": 37.1,
"6": 34.7
},
"timber": {
"1": 100.0,
"2": 60.0,
"3": 47.1,
"4": 40.0,
"5": 34.2,
"6": 32.0
}
}, },
"shoring": { "shoring": {
"note": "강관동바리(품셈 12-20)는 **슬래브를 떠받칠 때** 필요하다. 지금 서는 구조물(옹벽·집수정)은 벽체 거푸집만이라 대상이 아니다.", "note": "강관동바리(품셈 12-20)는 **슬래브를 떠받칠 때** 필요하다. 지금 서는 구조물(옹벽·집수정)은 벽체 거푸집만이라 대상이 아니다.",
"targets_pending": ["box_culvert", "ford_bridge"], "targets_pending": [
"box_culvert",
"ford_bridge"
],
"why": "그 둘은 원단위·치수가 미확보라 슬래브 면적 자체가 안 나온다 — 동바리도 함께 미확보" "why": "그 둘은 원단위·치수가 미확보라 슬래브 면적 자체가 안 나온다 — 동바리도 함께 미확보"
},
"euroform_type": {
"note": "유로폼 설치·해체 유형(간단/보통/복잡) — **품셈 12-38-3 [주]④ 원문**이 시설 예시로 갈라 둔다. 거푸집 사용횟수(1-7-1)·철근 갈래(12-3 [주]①)와 같은 자리로, 사람이 고르는 값이 아니다.",
"source": {
"doc": "산림사업 표준품셈 12-38-3 설치 및 해체 [주]④",
"table_id": "F0396",
"quote": "복잡 — 토목: 교대, 날개벽 등 복잡하고 보강이 많은 구조 / 보통 — 측구, 수로, 옹벽, 일반적인 벽체, 박스 등 / 간단 — 수문 또는 관의 기초, 건축 매트기초 등 간단한 구조"
},
"classes": [
{
"key": "복잡",
"examples": [
"교대",
"날개벽"
],
"daily_area_m2": 25
},
{
"key": "보통",
"examples": [
"측구",
"수로",
"옹벽",
"일반적인 벽체",
"박스"
],
"daily_area_m2": 35
},
{
"key": "간단",
"examples": [
"수문 기초",
"관의 기초",
"매트기초"
],
"daily_area_m2": 40
}
],
"type_map": [
{
"type_id": "retaining_wall",
"class": "보통",
"matched": "옹벽"
},
{
"type_id": "box_culvert",
"class": "보통",
"matched": "박스"
},
{
"type_id": "pipe_inlet_basin",
"class": "간단",
"matched": "관의 기초",
"note": "⚠ 집수정 벽체까지 간단으로 볼지는 확인 필요 — 거푸집 사용횟수와 같은 물음"
}
],
"reuse_note": "⚠ 12-38-1 「사용횟수」는 **1-7-1 과 다른 자리다.** 1-7-1 은 합판거푸집처럼 **소모성 거푸집을 몇 번 쓰나**(옹벽 3회)이고, 12-38-1 은 유로폼(강재)의 **잔존율 기준**(12회 사용 잔존율 25 % · 25회 사용 잔존율 10 %)으로 **임대료·손료 산정용**이다. 하나로 잇지 않는다.",
"material_unit_note": "유로폼 자재는 12-38-2 가 **10㎡당 패널 0.89매**로 낸다. B08 은 **접촉 면적(㎡)** 으로 보내고 매수 환산은 그 표의 밑수(10㎡)를 아는 B09 가 한다 — B08 이 환산하면 밑수를 두 벌로 들게 된다."
} }
} }
@@ -0,0 +1,70 @@
{
"schema_version": "1.0",
"dataset_id": "rebar_complexity",
"effective_date": "2026-01-01",
"note": "철근 가공·조립 갈래(간단/보통/복잡/매우복잡) — **품셈 12-3 [주]① 원문**이 구조물을 예시로 갈라 둔다. 사람이 고르는 값이 아니라 법으로 정해지는 값이다(거푸집 사용횟수 1-7-1 과 같은 자리).",
"source": {
"doc": "산림사업 표준품셈 12-3 철근 현장가공 및 조립 [주] ①",
"table_id": "F0335",
"quote": "간단한 것이란 측구, 간단한 기초 및 중력식 옹벽 등을 말하며, 보통의 것이란 수문, 반중력식 옹벽 및 교대 등을 말하고, 복잡한 것이란 교량의 슬래브, 암거, 우물통 부벽식 옹벽 등을 말하며, 매우 복잡한 것이란 구주식(기둥형) 교대, 교각, 지하철, 터널"
},
"policy": {
"auto_decided": true,
"unlisted_is_flagged": true,
"note": "원문 예시에 걸리는 것만 정한다. 안 걸리면 「갈래 미확보」로 드러내고 임의로 고르지 않는다."
},
"classes": [
{ "key": "간단", "examples": ["측구", "간단한 기초", "중력식 옹벽"] },
{ "key": "보통", "examples": ["수문", "반중력식 옹벽", "교대"] },
{ "key": "복잡", "examples": ["교량 슬래브", "암거", "우물통", "부벽식 옹벽"] },
{ "key": "매우복잡", "examples": ["구주식(기둥형) 교대", "교각", "지하철", "터널"] }
],
"form_map": [
{
"type_id": "retaining_wall",
"form": "중력식",
"class": "간단",
"matched": "중력식 옹벽"
},
{
"type_id": "retaining_wall",
"form": "반중력식",
"class": "보통",
"matched": "반중력식 옹벽"
},
{
"type_id": "retaining_wall",
"form": "부벽식",
"class": "복잡",
"matched": "부벽식 옹벽"
},
{
"type_id": "retaining_wall",
"form": "캔틸레버식",
"class": null,
"why": "원문 [주]① 예시에 캔틸레버식 옹벽이 없음 — 임의로 고르지 않고 미확보로 드러냄"
},
{
"type_id": "box_culvert",
"class": "복잡",
"matched": "암거"
},
{
"type_id": "pipe_inlet_basin",
"class": "간단",
"matched": "간단한 기초",
"note": "관보호공 집수정 — 원문의 「간단한 기초」에 해당. ⚠ 벽체까지 간단으로 볼지는 확인 필요"
}
],
"price_hint_krw_per_ton": {
"note": "⚠ **표시 전용.** 갈래를 자동으로 정하더라도 화면에 갈래 이름과 차이를 보여야 사람이 검증할 수 있다. B08 의 어떤 계산에도 안 들어간다(금액은 B09 몫).",
"computed_by": "B09",
"computed_on": "2026-09-07",
"values": {
"간단": 919146.7,
"보통": 1032497.4,
"복잡": 1143569.7,
"매우복잡": 1278375.3
}
}
}
@@ -165,21 +165,64 @@
{ {
"type_id": "retaining_wall", "type_id": "retaining_wall",
"parts": [ "parts": [
"FP-12-01-01#철근구조물", {
"FP-12-04 합판거푸집", "code": "FP-12-01-01",
"FP-12-03 철근 현장가공 및 조림", "name": "콘크리트 타설",
"FP-12-25 기초잡석" "unit": "㎥",
"from_components": [
"콘크리트",
"버림콘크리트"
],
"kind_suffix": "concrete_placing"
},
{
"code": "FP-12-04",
"name": "합판거푸집",
"unit": "㎡",
"from_components": [
"합판거푸집"
]
},
{
"code": "FP-12-38",
"name": "유로폼",
"unit": "㎡",
"from_components": [
"유로폼"
],
"kind_suffix": "euroform_type",
"incomplete_note": "⚠ 자재 몫이 빠져 있음 — 12-38-2 의 패널 0.89매/10㎡·부자재(주자재비의 24/52/79 %)·소모자재 5 % 는 **자재 단가가 서야** 붙는다. 지금 서는 것은 설치·해체 품뿐이다."
},
{
"code": "FP-12-03",
"name": "철근 현장가공 및 조립",
"unit": "ton",
"from_components": [
"이형철근 D13",
"이형철근 D16"
],
"unit_from": "kg",
"kind_suffix": "rebar_complexity"
},
{
"code": "FP-12-25",
"name": "기초잡석",
"unit": "㎥",
"from_components": [],
"not_ready": true,
"why": "관측 원단위에 기초잡석 물량이 없음 — 울진 라이브러리 반중력식 H=2.0 항목에 그 줄이 없다"
}
], ],
"why": "품셈 12장에 「옹벽」 공종이 없음. 실무 내역은 「반중력식옹벽 H=2.0」 한 줄이고 그 일위대가가 위 공종을 묶음.", "why": "품셈 12장에 「옹벽」 공종이 없음. 실무 내역은 「반중력식옹벽 H=2.0」 한 줄이고 그 일위대가가 위 공종을 묶음.",
"needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김", "needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김",
"placing_note": "타설 코드는 프로젝트 설정의 타설 방식으로 갈림(기본 레디믹스트). 철근구조물 판정은 원단위의 D13·D16 에서 자동으로 나옴.", "placing_note": "타설 코드는 프로젝트 설정의 타설 방식으로 갈림(기본 레디믹스트). 철근구조물 판정은 원단위의 D13·D16 에서 자동으로 나옴.",
"not_ready": [ "parts_note": "조각마다 **어느 성분에서 나오는지**(`from_components`)를 적는다. 이름을 바꾸면 물량이 조용히 0 이 되므로 원단위 성분 이름과 정확히 같아야 하고, 못 찾으면 드러낸다. `unit_from` 이 있으면 그 단위에서 목표 단위로 환산한다 — **철근은 ㎏ → ton (÷1000)**. ⚠ 단위를 안 맞추면 1000배 틀린다."
"FP-12-03",
"FP-12-25"
],
"not_ready_why": "B09 일위대가가 아직 안 섬 — 지금 세우면 절반짜리가 됨(2026-09-07 조율 창)"
} }
] ],
"unit_conversion": {
"note": "조각 단위 환산. 단가의 단위(원/ton)와 원단위의 단위(㎏)가 달라 반드시 맞춰야 한다.",
"kg_to_ton": 0.001
}
}, },
"concrete_placing": { "concrete_placing": {
"note": "콘크리트 타설은 **타설 방식 × 구조물 종류**로 갈린다. 방식은 설계 판단이라 프로젝트 설정(`quantity.concrete_placing_method`)이 고르고, 종류는 **원단위에 철근이 있나 없나로 자동 판정**한다 — 사람이 고르는 값이 아니다(2026-09-07 3자 확정).", "note": "콘크리트 타설은 **타설 방식 × 구조물 종류**로 갈린다. 방식은 설계 판단이라 프로젝트 설정(`quantity.concrete_placing_method`)이 고르고, 종류는 **원단위에 철근이 있나 없나로 자동 판정**한다 — 사람이 고르는 값이 아니다(2026-09-07 3자 확정).",
@@ -195,6 +238,17 @@
"철근구조물", "철근구조물",
"소형구조물" "소형구조물"
], ],
"kind_rule": "원단위 성분에 철근(이형철근·원형철근)이 있으면 철근구조물, 없으면 무근구조물. 소형구조물 판정 기준은 미확보." "kind_rule": "원단위 성분에 철근(이형철근·원형철근)이 있으면 철근구조물, 없으면 무근구조물. 소형구조물 판정 기준은 미확보.",
"price_hint_krw_per_m3": {
"note": "⚠ **표시 전용.** 사용자가 타설 방식을 고를 때 「정하면 얼마나 달라지는지」를 보이려고 둔 값이며 B08 의 어떤 계산에도 들어가지 않는다(금액은 B09 몫 — 8-2 경계). 값은 B09 가 2026-09-07 에 낸 철근구조물 기준 단가이고, 요율·노임이 바뀌면 어긋난다 — 화면이 「참고」임을 함께 적는다.",
"basis": "철근구조물 콘크리트 타설 (원/㎥)",
"computed_by": "B09",
"computed_on": "2026-09-07",
"values": {
"ready_mixed": 65826,
"machine_mixed": 163508,
"hand_mixed": 408327
}
}
} }
} }
@@ -1,7 +1,7 @@
{ {
"schema_version": "1.0", "schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest", "dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-08T01:00:54+09:00", "generated_at": "2026-09-08T01:45:21+09:00",
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py", "built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
"source": { "source": {
"dataset_id": "pum_forest", "dataset_id": "pum_forest",
@@ -12,8 +12,8 @@
"files": [ "files": [
{ {
"file": "work_item_master_2026-01-01.json", "file": "work_item_master_2026-01-01.json",
"sha256": "fe454c56c9dc01ad7dae04a8f90d776d08c5ce33badeadb2606b35234bfce7eb", "sha256": "f80d3ad4bf3ee242d282d8d54d18305e1b89fbbb9fc414c31b95cecc8b84d47f",
"size_bytes": 785034 "size_bytes": 813044
}, },
{ {
"file": "form_undetermined_2026-01-01.json", "file": "form_undetermined_2026-01-01.json",
@@ -22,8 +22,8 @@
}, },
{ {
"file": "basis_missing_2026-01-01.json", "file": "basis_missing_2026-01-01.json",
"sha256": "27f8d80d10b791df8502e633c1d279ce2acce63f871eae6b0c4eb26ca8290e2e", "sha256": "b69091149adb942ce507d902b7e6ac400777674301b9200b1f1c60cfbd709839",
"size_bytes": 20674 "size_bytes": 22832
} }
] ]
} }
@@ -34,12 +34,24 @@
"pum_form": "requirement", "pum_form": "requirement",
"line": 1444 "line": 1444
}, },
{
"pum_table_id": "F0057",
"section": "2-1-7. 소각, 매몰, 훈증, 박피",
"pum_form": "requirement",
"line": 1518
},
{ {
"pum_table_id": "F0061", "pum_table_id": "F0061",
"section": "2-1-11. 지상 약제살포", "section": "2-1-11. 지상 약제살포",
"pum_form": "requirement", "pum_form": "requirement",
"line": 1570 "line": 1570
}, },
{
"pum_table_id": "F0062",
"section": "2-1-12. 그물망 피복",
"pum_form": "requirement",
"line": 1579
},
{ {
"pum_table_id": "F0075", "pum_table_id": "F0075",
"section": "3-1. 경계표시", "section": "3-1. 경계표시",
@@ -292,6 +304,12 @@
"pum_form": "requirement", "pum_form": "requirement",
"line": 2919 "line": 2919
}, },
{
"pum_table_id": "F0145",
"section": "5-28-2. 방초매트 및 야자섬유매트 포장",
"pum_form": "requirement",
"line": 2929
},
{ {
"pum_table_id": "F0153", "pum_table_id": "F0153",
"section": "6-1. 비료주기", "section": "6-1. 비료주기",
@@ -322,6 +340,12 @@
"pum_form": "requirement", "pum_form": "requirement",
"line": 3122 "line": 3122
}, },
{
"pum_table_id": "F0158",
"section": "6-4-1. 덩굴걷기",
"pum_form": "requirement",
"line": 3143
},
{ {
"pum_table_id": "F0159", "pum_table_id": "F0159",
"section": "6-4-2. 덩굴 약제 살포처리", "section": "6-4-2. 덩굴 약제 살포처리",
@@ -610,6 +634,12 @@
"pum_form": "requirement", "pum_form": "requirement",
"line": 4860 "line": 4860
}, },
{
"pum_table_id": "F0256",
"section": "9-11-1. 콘크리트(기계)",
"pum_form": "requirement",
"line": 4946
},
{ {
"pum_table_id": "F0294", "pum_table_id": "F0294",
"section": "9-21. 제근", "section": "9-21. 제근",
@@ -622,6 +652,18 @@
"pum_form": "requirement", "pum_form": "requirement",
"line": 5770 "line": 5770
}, },
{
"pum_table_id": "F0311",
"section": "10-7-4. 모노레일 운반",
"pum_form": "requirement",
"line": 5779
},
{
"pum_table_id": "F0312",
"section": "10-7-4. 모노레일 운반",
"pum_form": "requirement",
"line": 5795
},
{ {
"pum_table_id": "F0313", "pum_table_id": "F0313",
"section": "10-8-1. 짐내리기", "section": "10-8-1. 짐내리기",
@@ -634,6 +676,12 @@
"pum_form": "requirement", "pum_form": "requirement",
"line": 5829 "line": 5829
}, },
{
"pum_table_id": "F0315",
"section": "10-8-3. 케이블 크레인 운전",
"pum_form": "requirement",
"line": 5840
},
{ {
"pum_table_id": "F0317", "pum_table_id": "F0317",
"section": "10-10-1. 콘크리트 및 골재운반(지상)", "section": "10-10-1. 콘크리트 및 골재운반(지상)",
@@ -646,6 +694,18 @@
"pum_form": "requirement", "pum_form": "requirement",
"line": 5894 "line": 5894
}, },
{
"pum_table_id": "F0326",
"section": "11-2. 토공의 비탈 규준틀",
"pum_form": "requirement",
"line": 6043
},
{
"pum_table_id": "F0327",
"section": "11-3. 수평규준틀",
"pum_form": "requirement",
"line": 6056
},
{ {
"pum_table_id": "F0337", "pum_table_id": "F0337",
"section": "12-5. 문양거푸집(07m)", "section": "12-5. 문양거푸집(07m)",
@@ -676,12 +736,36 @@
"pum_form": "requirement", "pum_form": "requirement",
"line": 6421 "line": 6421
}, },
{
"pum_table_id": "F0355",
"section": "12-17-1. 펌프카 타설",
"pum_form": "requirement",
"line": 6499
},
{
"pum_table_id": "F0362",
"section": "12-2 표면 마무리를 따른다.",
"pum_form": "requirement",
"line": 6569
},
{
"pum_table_id": "F0366",
"section": "12-19. 강관비계",
"pum_form": "requirement",
"line": 6614
},
{ {
"pum_table_id": "F0379", "pum_table_id": "F0379",
"section": "12-29. 스페이셔 설치(몰탈 블록)", "section": "12-29. 스페이셔 설치(몰탈 블록)",
"pum_form": "requirement", "pum_form": "requirement",
"line": 6775 "line": 6775
}, },
{
"pum_table_id": "F0395",
"section": "12-38-3. 설치 및 해체",
"pum_form": "requirement",
"line": 6943
},
{ {
"pum_table_id": "F0405", "pum_table_id": "F0405",
"section": "13-3. 기초다짐 및 뒤채움’ 항을 적용한다.", "section": "13-3. 기초다짐 및 뒤채움’ 항을 적용한다.",
@@ -700,6 +784,12 @@
"pum_form": "requirement", "pum_form": "requirement",
"line": 7185 "line": 7185
}, },
{
"pum_table_id": "F0424",
"section": "13-8. 막돌쌓기",
"pum_form": "requirement",
"line": 7415
},
{ {
"pum_table_id": "F0430", "pum_table_id": "F0430",
"section": "13-10-2. 나무 말뚝박기", "section": "13-10-2. 나무 말뚝박기",
@@ -730,6 +820,12 @@
"pum_form": "requirement", "pum_form": "requirement",
"line": 7745 "line": 7745
}, },
{
"pum_table_id": "F0449",
"section": "13-16-2. 통기성매트",
"pum_form": "requirement",
"line": 7788
},
{ {
"pum_table_id": "F0454", "pum_table_id": "F0454",
"section": "8-2-3 굴착기(2025)」를 참조하여 적용계수를 달리 적용하도록 한다.", "section": "8-2-3 굴착기(2025)」를 참조하여 적용계수를 달리 적용하도록 한다.",
File diff suppressed because it is too large Load Diff
+13
View File
@@ -628,6 +628,19 @@ export const ui_locales_b2 = {
B08_Quantity_Method_Unset: ["안 정함", "Not set"], B08_Quantity_Method_Unset: ["안 정함", "Not set"],
B08_Quantity_Method_Ripping: ["긁어내기(암절취)", "Ripping"], B08_Quantity_Method_Ripping: ["긁어내기(암절취)", "Ripping"],
B08_Quantity_Method_Blasting: ["터뜨리기(발파암)", "Blasting"], B08_Quantity_Method_Blasting: ["터뜨리기(발파암)", "Blasting"],
B08_Quantity_Side_Placing: ["콘크리트 타설", "Concrete Placing"],
B08_Quantity_Side_Placing_Label: ["타설 방식", "Method"],
B08_Quantity_Placing_Unset: ["안 정함(기본값 사용)", "Not set (default)"],
B08_Quantity_Placing_Current: ["적용 중", "In use"],
B08_Quantity_Placing_Default_Tag: ["기본값 — 확인 필요", "default — needs review"],
B08_Quantity_Placing_Hint: ["참고 단가(B09 산출):", "Reference unit price (from B09):"],
B08_Quantity_Placing_Ready: ["레디믹스트", "Ready-mixed"],
B08_Quantity_Placing_Machine: ["기계비빔", "Machine-mixed"],
B08_Quantity_Placing_Hand: ["인력비빔", "Hand-mixed"],
B08_Quantity_Placing_Default_Notice: [
"⚠ 기본값 「레디믹스트」로 계산 중 — 아직 안 정한 값입니다. 타설 방식에 따라 단가가 갈립니다.",
"⚠ Using the default (ready-mixed) — not yet decided. Unit prices differ by method.",
],
B08_Quantity_Material_Failed: [ B08_Quantity_Material_Failed: [
"자재총괄을 불러오지 못했습니다.", "자재총괄을 불러오지 못했습니다.",
"Failed to load the material summary.", "Failed to load the material summary.",