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

This commit is contained in:
2026-09-08 01:06:07 +09:00
24 changed files with 4853 additions and 444 deletions
@@ -237,6 +237,58 @@ def detect_form(table: dict[str, Any], chapter: str | None) -> tuple[str, str]:
BASIS_RE = re.compile(r"(\d[\d,.]*)\s*(㎥|m3|㎡|m2|㏊|ha|km|㎞|m|인|본|개|kg|㎏|톤|ton)\s*당")
# ⚠ **밑수는 표 안이 아니라 표 바로 위 본문에 있다** (2026-09-07 서브 창 제보로 파고 확인).
# `### 12-2. 표면 마무리` 다음 줄에 `(단위: ㎡당)` 이 오는 식이다. 표만 보면 못 찾고,
# 못 찾은 채로 두면 「10㎡당」 표를 1㎡당으로 알아 **곱셈이 10배 틀린다**.
# 앞서 확인한 「밑수가 **밀렸나**」와는 다른 물음이다 — 이번은 「**아예 안 적혔나**」다.
# ⚠ **「당」 또는 「단위:」 가 있어야 밑수다.** 둘 다 없으면 규격일 뿐이다 —
# 만들다 실제로 걸렸다: `(무한궤도,0.7㎥)` 를 「0.7㎥당」으로 읽어 5건이 잘못 잡혔다.
#: ⚠ `(단위: 인/㎡당)` 꼴 — **분모가 밑수**다. 값의 단위(인)를 밑수로 읽으면 뜻이 뒤집힌다.
SOURCE_BASIS_RATIO_RE = re.compile(
r"[(]\s*단위\s*[:]\s*[^)/]+/\s*([\d,]*\.?\d*)\s*"
r"(㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공|kg|㎏|톤|ton|주|식|대)\s*당\s*[)]"
)
SOURCE_BASIS_RE = re.compile(
r"[(]\s*(?:"
r"단위\s*[:]\s*([\d,]*\.?\d*)\s*(?P<u1>㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공|kg|㎏|톤|ton|주|식|대)\s*당?"
r"|([\d,]*\.?\d*)\s*(?P<u2>㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공|kg|㎏|톤|ton|주|식|대)\s*당"
r")\s*[)]"
)
#: 표 위로 몇 줄까지 거슬러 볼 것인가. 더 올라가면 **앞 표의 밑수**를 잘못 물어 온다.
SOURCE_LOOKBACK = 6
def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str | None]:
"""표 바로 위 본문에서 밑수를 읽는다. 못 찾으면 `(None, None)` — 1 로 단정하지 않는다.
⚠ 위로 거슬러 보되 **다른 표를 만나면 멈춘다**. 앞 표의 밑수를 물어 오면 조용히 틀린다.
"""
start = max(0, line_no - 1 - SOURCE_LOOKBACK)
for index in range(line_no - 2, start - 1, -1):
if index < 0 or index >= len(lines):
continue
text = lines[index].strip()
if text.startswith("|"):
break # 앞 표에 닿았다 — 그 위는 남의 밑수다
if m := SOURCE_BASIS_RATIO_RE.search(text):
raw = (m.group(1) or "").replace(",", "")
try:
quantity = float(raw) if raw else 1.0
except ValueError:
quantity = 1.0
return quantity, m.group(2)
if m := SOURCE_BASIS_RE.search(text):
unit = m.group("u1") or m.group("u2")
raw = (m.group(1) if m.group("u1") else m.group(3)) or ""
raw = raw.replace(",", "")
try:
quantity = float(raw) if raw else 1.0
except ValueError:
quantity = 1.0
return quantity, unit
return None, None
def detect_basis(table: dict[str, Any]) -> tuple[float | None, str | None]:
"""「100㎥당」 같은 밑수. 없으면 `(None, None)` — 단위당 1 로 단정하지 않는다."""
@@ -250,6 +302,36 @@ def detect_basis(table: dict[str, Any]) -> tuple[float | None, str | None]:
return None, None
# ⚠ **딱지가 비율을 달고 오는 표** — `인력(10%)` · `장비(90%)` (2026-09-07 서브 창 제보로 확인).
# 그 표는 소요량형이면서 **장비 몫이 시공능력 공식**(Q = 3600·q·K·f·E ÷ Cm)이라
# **인력 10 % 만 값으로 서 있다.** 형태 한 낱말(`requirement`)로만 적으면 받는 쪽이
# 그 단가를 전량에 곱해 **내역서가 9할 싸게** 선다. 그래서 **몫과 미완 여부를 따로 싣는다.**
SHARE_TAG_RE = re.compile(
r"^(자재|재료|재료비|자재비|잡재료|장비|기계|인력|노무|노무비|인건비|경비|공구손료)"
r"\s*[(]\s*(\d+(?:\.\d+)?)\s*[%]\s*[)]$"
)
#: 시공능력 공식 파라미터. 이 기호가 있으면 그 몫은 **아직 조립이 안 된 것**이다.
CAPACITY_SYMBOLS = {"K", "k", "f", "E", "Cm", "㎝(sec)", "q", "qo", "Q"}
def resource_shares(table: dict[str, Any]) -> dict[str, float]:
"""`{인력: 10.0, 장비: 90.0}` — 딱지에 붙은 몫. 없으면 빈 칸."""
shares: dict[str, float] = {}
for row in table.get("rows", []):
if not row:
continue
if m := SHARE_TAG_RE.match(norm(row[0])):
shares[m.group(1)] = float(m.group(2))
return shares
def capacity_formula_pending(table: dict[str, Any]) -> bool:
"""장비 몫이 **시공능력 공식**으로만 적혀 있어 아직 값이 안 된 상태인가."""
keys = {norm(row[0]) for row in table.get("rows", []) if row}
cells = {norm(c) for row in table.get("rows", []) for c in row}
return bool((keys | cells) & CAPACITY_SYMBOLS)
def variant_axis(table: dict[str, Any]) -> list[str]:
"""행이 갈리는 축 — 표의 첫 열 값들(토질·암종·규격). 값 열은 뺀다."""
seen: list[str] = []
@@ -260,14 +342,31 @@ def variant_axis(table: dict[str, Any]) -> list[str]:
return seen[:24]
def basis_quantity_is_grouped(quantity: float | None) -> bool:
"""「10㎡당」처럼 **묶음 기준**인가. 1 이 아니면 곱셈이 그만큼 갈린다."""
return quantity is not None and abs(quantity - 1.0) > 1e-9
def build() -> dict[str, Any]:
data = json.loads(SOURCE.read_text(encoding="utf-8"))
tables = data["variables"]["pum"]["tables"]
# 원문을 함께 연다 — 밑수가 표 밖(본문)에 있기 때문이다. 못 열면 밑수 없이 간다.
source_lines: list[str] = []
for entry in data.get("sources") or []:
candidate = ROOT / str(entry.get("path") or "")
if candidate.is_file():
source_lines = candidate.read_text(encoding="utf-8").splitlines()
break
toc_table = next(t for t in tables if t["table_id"] == "F0001")
nodes = parse_toc(toc_table["rows"])
by_number = {n["number"]: n for n in nodes}
attached = 0
# ⚠ 밑수를 못 찾은 표 목록. 「곱하면 안 되는 줄」을 받는 쪽이 가릴 수 있게 낸다 —
# 빈칸으로 두면 「1단위당」으로 오해되어 곱셈이 10배·100배 틀린다.
basis_missing: list[dict[str, Any]] = []
basis_found = 0
basis_grouped = 0
orphans: list[dict[str, Any]] = []
undetermined: list[dict[str, Any]] = []
@@ -278,7 +377,38 @@ def build() -> dict[str, Any]:
number = section_number(section)
chapter = number.split("-")[0] if number else None
form, why = detect_form(table, chapter)
# ⚠ **본문이 정본이다.** 표 안을 긁는 쪽은 보조 — 비고에 적힌 다른 기준
# (「10㎡당」 같은 참고 문구)을 그 표의 밑수로 잘못 물어 온다.
# 실제로 13-3-1 이 본문 `(단위: ㎥당)` 인데 표 안 긁기가 `10㎡` 를 물어 왔다.
basis_qty = basis_unit = None
if source_lines:
basis_qty, basis_unit = basis_from_source(source_lines, int(table.get("line") or 0))
if basis_qty is None and basis_unit is None:
basis_qty, basis_unit = detect_basis(table)
basis_source = "표 안" if basis_unit else None
else:
basis_source = "본문"
if basis_unit:
basis_found += 1
if basis_quantity_is_grouped(basis_qty):
basis_grouped += 1
elif form in ("requirement", "productivity"):
# 참조·계수표는 곱할 값이 아니므로 목록에 넣지 않는다 — 잡음이 되면 안 본다.
basis_missing.append(
{
"pum_table_id": table["table_id"],
"section": norm(table.get("section")),
"pum_form": form,
"line": table.get("line"),
}
)
shares = resource_shares(table)
# ⚠ 「값이 일부만 선 표」를 정상으로 흘려보내지 않는다. **몫이 적혀 있으면 부분값**으로
# 본다 — 관측한 25건 모두 장비 몫이 시공능력 공식으로만 적혀 있어 값이 아니었고,
# 공식 기호가 첫 표에만 있고 이어지는 표는 그것을 물려받는 모양이라
# 「기호가 있는 표만」으로 세면 절반을 놓친다(9-13-2 가 그 경우).
# ⚠ 이 깃발은 **표시일 뿐 값을 지우지 않는다** — 넓게 잡아도 정상 값이 안 사라진다.
partial = bool(shares)
entry = {
"pum_table_id": table["table_id"],
"section": section,
@@ -287,6 +417,12 @@ def build() -> dict[str, Any]:
"form_basis": why,
"basis_quantity": basis_qty,
"basis_unit": basis_unit,
# 밑수를 어디서 읽었나 — 본문(정본) / 표 안(보조). 없으면 None.
"basis_source": basis_source,
"resource_shares": shares,
"partial_ratio": partial,
# 공식 기호가 이 표에 직접 있는가 — 없으면 앞 표에서 물려받는 모양이다.
"capacity_formula_here": capacity_formula_pending(table),
"variant_key": variant_axis(table),
"condition_note": [norm(h) for h in table.get("headers", []) if norm(h)],
"raw_row": table.get("rows", []), # 원문 셀 — B09 자원 축이 읽는다.
@@ -314,7 +450,8 @@ def build() -> dict[str, Any]:
"sha256": sha256_of(SOURCE),
"file": SOURCE.name,
}
return {
return (
{
"schema_version": SCHEMA_VERSION,
"dataset_id": "work_item_master_forest",
"effective_date": data["effective_date"],
@@ -332,18 +469,25 @@ def build() -> dict[str, Any]:
"tables_attached": attached,
"tables_orphan": len(orphans),
"form_undetermined": len(undetermined),
"basis_found": basis_found,
"basis_missing": len(basis_missing),
"basis_grouped": basis_grouped,
},
"orphan_tables": orphans,
"work_items": nodes,
}, undetermined
},
undetermined,
basis_missing,
)
def main() -> None:
master, undetermined = build()
master, undetermined, basis_missing = build()
OUT_DIR.mkdir(parents=True, exist_ok=True)
date = master["effective_date"]
master_path = OUT_DIR / f"work_item_master_{date}.json"
undet_path = OUT_DIR / f"form_undetermined_{date}.json"
basis_path = OUT_DIR / f"basis_missing_{date}.json"
master_path.write_text(json.dumps(master, ensure_ascii=False, indent=1), encoding="utf-8")
undet_path.write_text(
@@ -361,6 +505,24 @@ def main() -> None:
encoding="utf-8",
)
basis_path.write_text(
json.dumps(
{
"schema_version": SCHEMA_VERSION,
"dataset_id": "work_item_master_basis_missing",
"effective_date": date,
"note": (
"밑수(「10㎡당」 같은 기준 수량)를 못 찾은 표. **1 단위당으로 단정하지 말 것** — "
"곱셈이 10배·100배 틀린다. 값을 곱해야 하는 형태(requirement·productivity)만 담는다."
),
"items": basis_missing,
},
ensure_ascii=False,
indent=1,
),
encoding="utf-8",
)
manifest = {
"schema_version": SCHEMA_VERSION,
"dataset_id": "data_work_item_master_manifest",
@@ -373,7 +535,7 @@ def main() -> None:
"sha256": sha256_of(p),
"size_bytes": p.stat().st_size,
}
for p in (master_path, undet_path)
for p in (master_path, undet_path, basis_path)
],
}
(OUT_DIR / "_manifest.json").write_text(
@@ -386,6 +548,8 @@ def main() -> None:
f"표 귀속 {s['tables_attached']} / {s['tables_total']} (미귀속 {s['tables_orphan']})"
)
print(f"형태 미판정 {s['form_undetermined']}")
print(f"밑수 확보 {s['basis_found']} (묶음 기준 {s['basis_grouped']})")
print(f"밑수 미확보 {s['basis_missing']}{basis_path.name}")
print(f"산출 {master_path.relative_to(ROOT)}")
@@ -44,7 +44,19 @@ class SummaryRow:
item: str = "" # 공종 (토사·연암·…)
spec: str = "" # 규격 (기계(굴삭기)·백호우·…)
unit: str = ""
amount: float = 0.0
amount: float = 0.0 # 반영률을 **곱한 뒤** 값 — 내역서에 쓰는 값
# ⚠ 반영률 **적용 전** 값과 쓴 율을 함께 남긴다 (2026-09-07 3자 계약).
# 곱하기는 **B08 한 곳에서만** 한다. B09 가 율만 보고 또 곱하면 값이 두 배가 된다.
# 반영률 개념이 없는 줄은 `None` 이고, 100 % 인 줄도 **100.0 을 적는다** —
# 칸이 비어 있으면 「적용됐는지」를 받는 쪽이 단정할 수 없다.
amount_gross: float | None = None
application_ratio_pct: float | None = None
# ⚠ 성·절토면이 갈리는 줄은 **늘 갈래별로** 싣는다 (2026-09-07 3자 계약 확정).
# 「율이 같을 때만 pct, 다를 때만 breakdown」으로 두면 받는 쪽에 갈래가 둘 생기고
# 그게 **한쪽만 고쳐지는** 자리가 된다. `application_ratio_pct` 는 두 율이 같을 때만
# 채우는 **편의값**이고, 정본은 아래 두 칸이다.
application_ratio_breakdown: dict[str, float] | None = None
quantity_breakdown: dict[str, float] | None = None
note: str = ""
# 내역서 줄이 되는가 — 무대처럼 품에 포함된 것은 False (PLAN 8-7 ㉡).
in_bill: bool = True
@@ -83,9 +95,7 @@ def _split_by_rock(total: float, source: SummaryInput) -> list[tuple[str, float,
if given <= 0:
return [("", total, "")]
note = "" if abs(given - 100.0) < 1e-9 else f"입력 합 {given:g} % → 100 % 로 안분"
return [
(name, total * ratios[name] / given, note) for name in classes if ratios[name] > 0
]
return [(name, total * ratios[name] / given, note) for name in classes if ratios[name] > 0]
def build_rows(source: SummaryInput) -> list[SummaryRow]:
@@ -106,9 +116,7 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
)
for name, amount, note in _split_by_rock(earth.get(rock_key, 0.0), source):
rows.append(
SummaryRow(
group=group, item=name, spec="굴삭기+브레카", amount=amount, note=note
)
SummaryRow(group=group, item=name, spec="굴삭기+브레카", amount=amount, note=note)
)
rows.append(SummaryRow(group="보정량계", amount=earth.get("adjusted_total_m3", 0.0)))
@@ -125,18 +133,39 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
group="성토면다짐",
unit="",
amount=fill_face * _ratio(source, "fill_slope_compaction"),
amount_gross=fill_face,
application_ratio_pct=_ratio(source, "fill_slope_compaction") * 100.0,
application_ratio_breakdown={"fill": _ratio(source, "fill_slope_compaction") * 100.0},
quantity_breakdown={"fill": fill_face * _ratio(source, "fill_slope_compaction")},
note=_ratio_note(source, "fill_slope_compaction", "성토면"),
)
)
seed = fill_face * _ratio(source, "seed_spray_fill") + cut_face * _ratio(
source, "seed_spray_cut"
)
# ⚠ 성·절토면 율이 다를 수 있어 **한 줄에 하나의 율**로 못 적는다. 적용 전 합을 함께 두고
# 율은 두 율이 같을 때만 적는다 — 다르면 `None` 이고 비고에 두 율이 적힌다.
seed_gross = fill_face + cut_face
seed_fill_ratio = _ratio(source, "seed_spray_fill")
seed_cut_ratio = _ratio(source, "seed_spray_cut")
rows.append(
SummaryRow(
group="초류종자살포",
spec="씨드스프레이",
unit="",
amount=seed,
amount_gross=seed_gross,
application_ratio_pct=(
seed_fill_ratio * 100.0 if seed_fill_ratio == seed_cut_ratio else None
),
application_ratio_breakdown={
"fill": seed_fill_ratio * 100.0,
"cut": seed_cut_ratio * 100.0,
},
quantity_breakdown={
"fill": fill_face * seed_fill_ratio,
"cut": cut_face * seed_cut_ratio,
},
note=_seed_note(source),
)
)
@@ -146,6 +175,16 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
group="지장목제거",
unit="",
amount=removal * _ratio(source, "obstacle_removal"),
amount_gross=removal,
application_ratio_pct=_ratio(source, "obstacle_removal") * 100.0,
application_ratio_breakdown={
"fill": _ratio(source, "obstacle_removal") * 100.0,
"cut": _ratio(source, "obstacle_removal") * 100.0,
},
quantity_breakdown={
"fill": slope.get("tree_removal_fill", 0.0) * _ratio(source, "obstacle_removal"),
"cut": slope.get("tree_removal_cut", 0.0) * _ratio(source, "obstacle_removal"),
},
note=_ratio_note(source, "obstacle_removal", "성토면+절토면"),
)
)
@@ -215,6 +254,10 @@ def build_table(source: SummaryInput) -> dict[str, Any]:
"spec": row.spec,
"unit": row.unit,
"amount": row.amount,
"amount_gross": row.amount_gross,
"application_ratio_pct": row.application_ratio_pct,
"application_ratio_breakdown": row.application_ratio_breakdown,
"quantity_breakdown": row.quantity_breakdown,
"note": row.note,
"in_bill": row.in_bill,
}
@@ -0,0 +1,127 @@
"""거푸집 사용횟수 — 접촉 면적에 **몇 회 쓰는 거푸집인지**를 붙인다 (B08 일감 ⑩).
⚠ 사용횟수는 **관측값이 아니라 법이다**
품셈 1-7-1 이 구조물 종류별로 정해 둔다 — 「3회 … 옹벽, 파라펫트, 날개벽 등 약간 복잡한
구조」. 그래서 실무 관측값으로 갈음하지 않고 **원문 문구를 그대로 데이터에 싣고** 우리
구조물이 그 줄의 어느 예시에 걸리는지를 적는다. 걸리는 예시가 없으면 지어내지 않는다.
⚠⚠ **횟수별 재료 환산은 여기서 하지 않는다** (이중계상)
품셈 12-4 의 「사용횟수별 기준수량에 대한 비율(%)」(합판 3회 46.1 % 등)은 **일위대가
재료비**에 걸리는 값이다. B08 이 면적에 그 비율을 곱해 넘기면 B09 가 또 곱해 두 번 준다.
**B08 이 내는 것은 「접촉 면적 + 몇 회짜리인가」까지다.** 비율표는 참고로만 싣는다.
⚠ 동바리는 지금 대상이 없다
강관동바리(12-20)는 **슬래브를 떠받칠 때** 쓴다. 지금 서는 구조물(옹벽·집수정)은 벽체
거푸집뿐이라 대상이 아니고, 대상이 될 BOX암거·세월교는 치수·원단위가 미확보라
슬래브 면적 자체가 안 나온다. **없는 것을 0 으로 적지 않고 「대상 없음」이라고 말한다.**
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_formwork"
DATASET_PREFIX = "formwork_reuse_"
#: 거푸집으로 보는 성분 이름. 정확히 같은 이름으로만 본다 — 부분일치면 「거푸집씻기」가 걸린다.
FORMWORK_NAMES = frozenset({"합판거푸집", "유로폼", "문양거푸집", "거푸집"})
NOTE_REUSE_MISSING = "사용횟수 미확보"
NOTE_NOT_APPLICABLE = "거푸집 대상 아님"
def _latest_dataset_path(directory: Path | None = None) -> Path | None:
folder = directory or DATASET_DIR
if not folder.is_dir():
return None
files = sorted(folder.glob(DATASET_PREFIX + "*.json"))
return files[-1] if files else None
@dataclass
class FormworkTable:
"""사용횟수표 한 판."""
effective_date: str = ""
source: dict[str, Any] = field(default_factory=dict)
type_map: 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)
shoring: dict[str, Any] = field(default_factory=dict)
def for_type(self, type_id: str) -> dict[str, Any] | None:
for row in self.type_map:
if row.get("type_id") == type_id:
return row
return None
def load_formwork_table(path: Path | None = None) -> FormworkTable:
"""사용횟수표를 읽는다. 파일이 없으면 **빈 표** — 전부 「미확보」로 드러난다."""
target = path or _latest_dataset_path()
if target is None or not target.is_file():
return FormworkTable()
payload = json.loads(target.read_text(encoding="utf-8"))
return FormworkTable(
effective_date=str(payload.get("effective_date") or ""),
source=payload.get("source") or {},
type_map=list(payload.get("type_map") or []),
reuse_by_class=list(payload.get("reuse_by_class") or []),
reuse_ratio_pct=payload.get("reuse_ratio_pct") or {},
shoring=payload.get("shoring") or {},
)
def annotate(
structures: list[dict[str, Any]], table: FormworkTable | None = None
) -> tuple[list[str], list[str]]:
"""산출물의 거푸집 성분에 사용횟수를 달아 준다. (알림, 미확보 종류) 를 돌려준다.
성분 딕셔너리를 **그 자리에서** 고친다 — 거푸집 줄만 손대고 나머지는 건드리지 않는다.
"""
found = table or load_formwork_table()
notes: list[str] = []
missing: list[str] = []
for structure in structures:
type_id = str(structure.get("type_id") or "")
entry = found.for_type(type_id)
targets = [
component
for component in structure.get("components") or []
if str(component.get("name") or "").strip() in FORMWORK_NAMES
]
if not targets:
continue
if entry is None:
missing.append(type_id)
for component in targets:
component["reuse_count"] = None
component["reuse_note"] = NOTE_REUSE_MISSING
continue
count = entry.get("reuse_count")
for component in targets:
component["reuse_count"] = count
component["reuse_note"] = (
f"품셈 1-7-1 {count}회 — 「{entry.get('matched_example')}"
if count
else NOTE_NOT_APPLICABLE
)
if count:
notes.append(f"{structure.get('name') or type_id} 거푸집 {count}회 (품셈 1-7-1)")
else:
missing.append(type_id)
return notes, sorted(set(missing))
def shoring_status(table: FormworkTable | None = None) -> dict[str, Any]:
"""동바리 — **대상이 없으면 없다고 말한다.** 0 으로 적으면 「없음」과 구별이 안 된다."""
found = table or load_formwork_table()
shoring = found.shoring or {}
return {
"applicable": False,
"reason": str(shoring.get("note") or "슬래브 구조물이 없어 동바리 대상이 아님"),
"pending_types": list(shoring.get("targets_pending") or []),
}
+161 -9
View File
@@ -18,6 +18,17 @@
④예산내역서에 무대가 서서 운반비가 두 번 붙는다. 빼고 넘기지 않는 까닭은,
빠진 줄과 제외된 줄을 나중에 구별할 수 없기 때문이다.
⚠ **반영률은 B08 한 곳에서만 곱한다** (2026-09-07 3자 계약)
`quantity` 는 **곱한 뒤** 값이고 `quantity_gross` 는 곱하기 전 값이며 `application_ratio_pct`
는 쓴 율이다. **셋을 함께 싣는 까닭**은 받는 쪽이 「이미 곱해졌나」를 단정할 수 있어야
하기 때문이다 — 율만 보내면 B09 가 또 곱해 값이 두 배가 된다. 100 % 인 줄도 `100.0` 을
적고, `None` 은 **반영률 개념이 없는 줄**에만 쓴다.
`verify_ratio_math()` 가 세 값이 서로 맞는지 실제로 재 본다.
성·절토면이 갈리는 줄은 **늘** `application_ratio_breakdown`(갈래별 율)과
`quantity_breakdown`(갈래별 물량)을 싣는다. `application_ratio_pct` 는 두 율이 같을 때만
채우는 **편의값**이다 — 「같을 때만 pct, 다를 때만 breakdown」으로 두면 받는 쪽에 갈래가
둘 생기고 그게 한쪽만 고쳐지는 자리가 된다(2026-09-07 3자 계약).
⚠ `ground_class_set` 을 함께 싣는다 (2026-09-07 서브 이견 채택)
값이 「연암」이어도 **그 프로젝트가 몇 갈래 세트를 쓰는지**를 알아야 ④예산내역서에서 줄을
세울 수 있다(울진 2 · 거창 5 · 오솔길 1). 설정 파일을 안 봐도 **인계본만으로 ④가 서게** 한다.
@@ -34,6 +45,8 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
from common_util.common_util_quantity_spread import spread_by_unit
DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_work_item_mapping"
DATASET_PREFIX = "work_item_mapping_"
@@ -43,6 +56,16 @@ ORIGIN_STRUCTURE = "structure"
ORIGIN_SLOPE = "slope"
ORIGIN_HAUL = "haul"
#: 암 시공법 → 매핑표의 지반 이름. 품셈이 **긁어내기와 터뜨리기를 다른 공종**으로 두기 때문에
#: 갈래 이름(연암·보통암…)만으로는 공종을 못 고른다(2026-09-07 일감 9 실서버에서 드러남).
METHOD_TO_GROUND = {"ripping": "리핑암", "blasting": "발파암"}
NOTE_METHOD_MISSING = "시공법 미지정으로 공종을 못 고름"
#: 철근으로 보는 성분 이름 조각. **정확한 낱말이 아니라 앞머리**로 본다 —
#: 「이형철근 D13」·「원형철근」처럼 규격이 뒤에 붙기 때문이다. `철근콘크리트`는 성분 이름이
#: 아니라 공종 이름이라 성분 목록에는 안 온다.
REBAR_PREFIXES = ("이형철근", "원형철근", "철근")
#: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다.
SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"})
@@ -67,8 +90,10 @@ class WorkItemMapping:
haul: list[dict[str, Any]] = field(default_factory=list)
structure: list[dict[str, Any]] = field(default_factory=list)
pending_user: dict[str, Any] = field(default_factory=dict)
composite: dict[str, Any] = field(default_factory=dict)
concrete_placing: dict[str, Any] = field(default_factory=dict)
def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None:
def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401
"""공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다."""
exact = [
row
@@ -93,6 +118,16 @@ class WorkItemMapping:
return row
return None
def composite_for(self, type_id: str) -> dict[str, Any] | None:
"""품셈에 그 이름의 공종이 없어 **여러 공종을 묶는** 자리인가.
빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다 — 묶음을 적어 구별한다.
"""
for row in self.composite.get("items") or []:
if row.get("type_id") == type_id:
return row
return None
def load_mapping(path: Path | None = None) -> WorkItemMapping:
"""매핑표를 읽는다. 파일이 없으면 **빈 표** — 전 줄이 `unmatched` 로 드러난다."""
@@ -106,9 +141,34 @@ def load_mapping(path: Path | None = None) -> WorkItemMapping:
haul=list(payload.get("haul") or []),
structure=list(payload.get("structure") or []),
pending_user=payload.get("pending_user") or {},
composite=payload.get("composite") or {},
concrete_placing=payload.get("concrete_placing") or {},
)
def structure_kind(structure: dict[str, Any]) -> str:
"""콘크리트 구조물 종류 — **원단위에 철근이 있나 없나로 판정한다.**
사람이 고르는 값이 아니다(2026-09-07 3자 확정). 옹벽 관측 원단위에 `D13`·`D16` 이
실려 있으므로 철근구조물로 자동으로 선다. 소형구조물 판정 기준은 아직 없다.
"""
for component in structure.get("components") or []:
name = str(component.get("name") or "").strip()
if any(name.startswith(prefix) for prefix in REBAR_PREFIXES):
return "철근구조물"
return "무근구조물"
def placing_code(mapping: WorkItemMapping, method: str | None) -> tuple[str | None, bool]:
"""(타설 공종코드, 기본값을 쓴 것인가). 모르는 방식이면 기본으로 떨어지되 그 사실을 알린다."""
table = mapping.concrete_placing or {}
codes = table.get("method_codes") or {}
default = str(table.get("default_method") or "")
if method in codes:
return codes[method], False
return codes.get(default), True
def _spec_detail(structure: dict[str, Any]) -> str:
"""규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다."""
parts: list[str] = []
@@ -121,8 +181,25 @@ def _spec_detail(structure: dict[str, Any]) -> str:
return "·".join(parts)
def _mapping_ground(ground: str | None, methods: dict[str, str | None]) -> tuple[str | None, str]:
"""갈래 이름을 **매핑표가 아는 이름**으로 바꾼다.
「토사」는 그대로 가고, 암 갈래는 **시공법이 정해져야** 리핑암·발파암으로 간다.
안 정했으면 `(None, 사유)` — 찍지 않는다. 잘못 찍으면 공종이 조용히 틀린다.
"""
if ground is None or ground == "토사":
return ground, ""
method = methods.get(ground)
mapped = METHOD_TO_GROUND.get(method or "")
if mapped:
return mapped, ""
return None, NOTE_METHOD_MISSING
def _earthwork_rows(
summary_table: dict[str, Any], mapping: WorkItemMapping
summary_table: dict[str, Any],
mapping: WorkItemMapping,
methods: dict[str, str | None],
) -> tuple[list[dict[str, Any]], list[str]]:
"""토공집계표 줄을 내역 줄로 옮긴다.
@@ -138,10 +215,12 @@ def _earthwork_rows(
ground = row.get("item") or None
origin = ORIGIN_SLOPE if group in SLOPE_GROUPS else ORIGIN_EARTHWORK
is_subtotal = group in SUBTOTAL_GROUPS
entry = mapping.for_earthwork(group, ground)
lookup_ground, method_note = _mapping_ground(ground, methods)
entry = mapping.for_earthwork(group, lookup_ground) if method_note == "" else None
code = (entry or {}).get("work_item_code")
if code is None and not is_subtotal:
unmatched.append(f"{group}({ground})" if ground else group)
label = f"{group}({ground})" if ground else group
unmatched.append(f"{label}{method_note}" if method_note else label)
rows.append(
{
"work_item_code": code,
@@ -149,6 +228,12 @@ def _earthwork_rows(
"spec": str(row.get("spec") or ""),
"unit": str(row.get("unit") or ""),
"quantity": float(row.get("amount") or 0.0),
# 반영률 — 곱하기는 여기가 끝이다. 받는 쪽은 적기만 한다.
"quantity_gross": row.get("amount_gross"),
"application_ratio_pct": row.get("application_ratio_pct"),
# 율이 부분마다 다른 줄 — 받는 쪽이 문장을 안 뜯게 칸으로 준다.
"application_ratio_breakdown": row.get("application_ratio_breakdown"),
"quantity_breakdown": row.get("quantity_breakdown"),
"ground_class": ground,
"haul_distance_m": None,
"haul_equipment": None,
@@ -157,6 +242,7 @@ def _earthwork_rows(
"spec_detail": "",
# 합계 줄과 무대 줄은 값은 내되 내역에 안 선다.
"in_bill": bool(row.get("in_bill", True)) and not is_subtotal,
"excavation_method": methods.get(ground) if ground else None,
"in_bill_reason": "집계 합계 줄 — 검산용"
if is_subtotal
else str(row.get("note") or ""),
@@ -186,6 +272,11 @@ def _haul_rows(
"spec": str(row.get("ground") or ""),
"unit": "",
"quantity": float(row.get("volume_m3") or 0.0),
# 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다).
"quantity_gross": None,
"application_ratio_pct": None,
"application_ratio_breakdown": None,
"quantity_breakdown": None,
"ground_class": row.get("ground") or None,
"haul_distance_m": float(row.get("average_distance_m") or 0.0),
"haul_equipment": equipment,
@@ -214,7 +305,9 @@ def _structure_rows(
type_id = str(structure.get("type_id") or "")
entry = mapping.for_structure(type_id) or {}
code = entry.get("work_item_code")
if code is None:
composite = mapping.composite_for(type_id) if code is None else None
kind = structure_kind(structure) if composite else None
if code is None and composite is None:
unmatched.append(f"구조물({type_id})")
length = float(structure.get("length_m") or 0.0)
rows.append(
@@ -224,14 +317,24 @@ def _structure_rows(
"spec": _spec_detail(structure),
"unit": "m",
"quantity": length,
"quantity_gross": None,
"application_ratio_pct": None,
"application_ratio_breakdown": None,
"quantity_breakdown": None,
"ground_class": None,
"haul_distance_m": None,
"haul_equipment": None,
"station_from": structure.get("start_m"),
"station_to": structure.get("end_m"),
"spec_detail": _spec_detail(structure),
# 품셈에 그 이름의 공종이 없어 여러 공종을 묶는 자리 — 빈 코드와 구별한다.
"composite_parts": (composite or {}).get("parts"),
# 철근이 있나 없나로 자동 판정 — 사람이 고르는 값이 아니다.
"structure_kind": kind,
# ⚠ 아직 일위대가가 안 선 공종 — 지금 세우면 절반짜리가 된다.
"composite_not_ready": (composite or {}).get("not_ready"),
"in_bill": True,
"in_bill_reason": "",
"in_bill_reason": (composite or {}).get("why", ""),
"origin": ORIGIN_STRUCTURE,
}
)
@@ -268,14 +371,16 @@ def build_handoff(
mapping: WorkItemMapping | None = None,
ground_class_set: str | None = None,
ground_classes: list[str] | None = None,
ground_methods: dict[str, str | None] | None = None,
) -> dict[str, Any]:
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**."""
table = mapping or load_mapping()
work_items: list[dict[str, Any]] = []
unmatched: list[str] = []
methods = {key: value for key, value in (ground_methods or {}).items() if value}
if summary_table:
rows, misses = _earthwork_rows(summary_table, table)
rows, misses = _earthwork_rows(summary_table, table, methods)
work_items.extend(rows)
unmatched.extend(misses)
if haul_table:
@@ -288,20 +393,44 @@ def build_handoff(
unmatched.extend(misses)
materials = _material_rows(material_table or {})
return {
result: dict[str, Any] = {
"work_items": work_items,
"materials": materials,
# 갈래 세트 — 「연암」이 몇 갈래 중 하나인지 알아야 ④가 선다.
"ground_class_set": ground_class_set,
"ground_classes": list(ground_classes or []),
"ground_methods": dict(methods),
# 시공법을 안 정해 공종을 못 고른 갈래 — 화면이 이 목록으로 안내를 띄운다.
"missing_method_classes": sorted(
{
str(row.get("ground_class"))
for row in work_items
if row.get("ground_class")
and row.get("ground_class") != "토사"
and row.get("work_item_code") is None
and row.get("origin") == ORIGIN_EARTHWORK
}
),
# 자재 쪽에만 할증이 있다 — 작업 공종에는 없다.
# ⚠ 세 갈래로 그대로 나른다(`applied`·`not_applied`·`rate_unavailable`).
# 「율이 없어 못 붙인 것」을 「붙였다」로 말하면 B09 가 나중에 한 번 더 붙인다.
"surcharge_status": (material_table or {}).get("surcharge_status"),
"surcharge_applied_to_materials": bool((material_table or {}).get("surcharge_applied")),
"unmatched_work_items": sorted(set(unmatched)),
"mapping_pending_user": table.pending_user,
"mapping_edition": table.effective_date,
"quantity_spread": spread_by_unit(
[row for row in work_items if row["in_bill"]], value_key="quantity"
),
"material_spread": spread_by_unit(
[{"unit": row["unit"], "q": row["total_amount"]} for row in materials], value_key="q"
),
"bill_row_count": sum(1 for row in work_items if row["in_bill"]),
"excluded_row_count": sum(1 for row in work_items if not row["in_bill"]),
}
# ⚠ 검사는 **실제로 부른다** — 만들어 두고 안 부르면 없는 것과 같다.
result["ratio_math_warnings"] = verify_ratio_math(result)
return result
def verify_no_code_on_materials(handoff: dict[str, Any]) -> list[str]:
@@ -317,6 +446,25 @@ def verify_no_code_on_materials(handoff: dict[str, Any]) -> list[str]:
return found
def verify_ratio_math(handoff: dict[str, Any], *, tolerance: float = 1e-6) -> list[str]:
"""⚠ `quantity == quantity_gross × 율/100` 이 실제로 맞는지 재 본다.
세 칸을 실어 두고 **서로 어긋나면** 받는 쪽이 어느 값을 믿을지 알 수 없다.
「만들어 두고 안 부르면 없는 것과 같다」를 피하려고 `build_handoff()` 가 직접 부른다.
"""
found: list[str] = []
for row in handoff.get("work_items") or []:
gross = row.get("quantity_gross")
ratio = row.get("application_ratio_pct")
if gross is None or ratio is None:
continue
expected = float(gross) * float(ratio) / 100.0
actual = float(row.get("quantity") or 0.0)
if abs(expected - actual) > max(tolerance, abs(expected) * 1e-9):
found.append(f"{row.get('name')}: {actual:g}{gross:g} × {ratio:g} %")
return found
def verify_bill_flags(handoff: dict[str, Any]) -> list[str]:
"""⚠ 코드가 없는데 내역에 서는 줄이 있으면 알린다.
@@ -324,7 +472,11 @@ def verify_bill_flags(handoff: dict[str, Any]) -> list[str]:
"""
found: list[str] = []
for row in handoff.get("work_items") or []:
if row.get("in_bill") and not row.get("work_item_code"):
if not row.get("in_bill") or row.get("work_item_code"):
continue
# 묶음으로 서는 줄은 코드가 없어도 정상이다 — 무엇으로 묶이는지 적혀 있다.
if row.get("composite_parts"):
continue
found.append(str(row.get("name")))
return found
@@ -23,6 +23,10 @@
「품셈 항목에 할증이 포함ㆍ표시된 경우 중복 적용 금지」. 성분이 그렇게 표시돼 오면
(`surcharge_included: True`) 율을 붙이지 않고 비고에 까닭을 남긴다.
⚠ 관급구분은 **세 값**이다 — `owner_supplied` · `contractor_supplied` · `unknown`
`unknown` 은 「아직 안 정함」이고 **지어내지 않겠다는 뜻**이다. B09 는 이 줄을 관급자재대에도
도급 재료비에도 넣지 않고 `missing` 으로 뺀다(2026-09-07 계약에 명시).
⚠ 관급/사급은 **법이 아니라 발주 결정**이다
자재마다 정해진 값이 아니므로 지어내지 않는다. 프로젝트 설정
(`quantity.material_supply`)이 정한 것만 따르고, 안 정한 자재는 `unknown` 으로 남겨
@@ -46,6 +50,8 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
from common_util.common_util_quantity_spread import spread_by_unit
# ── 데이터 자리 ──────────────────────────────────────────────────────
DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_material_surcharge"
DATASET_PREFIX = "material_surcharge_"
@@ -69,6 +75,14 @@ INSTALL_BY_OWNER = "owner" # 관 직접설치
INSTALL_BY_LABELS = {INSTALL_BY_CONTRACTOR: "도급자설치", INSTALL_BY_OWNER: "관 직접설치"}
NOTE_INSTALL_BY_MISSING = "설치 주체 미지정"
#: ⚠ 할증 상태는 **세 갈래**다 (2026-09-07 3자 계약 정정).
#: 두 갈래(`True`/`False`)로 두면 「율을 못 찾아 안 붙인 것」이 「붙였다」로 나가고,
#: 나중에 진짜 율이 들어왔을 때 B09 가 한 번 더 붙인다. **깃발과 실제가 어긋나지 않을 것**이
#: 요건이므로 상태를 그대로 말한다.
SURCHARGE_APPLIED = "applied" # 한 줄이라도 실제로 붙음
SURCHARGE_NOT_APPLIED = "not_applied" # 붙일 줄이 없음(자재 자체가 없음)
SURCHARGE_RATE_UNAVAILABLE = "rate_unavailable" # 자재는 있는데 율을 못 찾음
NOTE_RATE_MISSING = "할증률 미확보"
NOTE_INCLUDED = "품셈에 할증 포함 — 중복 적용 안 함"
@@ -159,6 +173,15 @@ class MaterialRow:
return " · ".join(parts)
def _surcharge_status(rows: list[MaterialRow]) -> str:
"""할증이 실제로 붙었는가 — 세 갈래로 답한다."""
if not rows:
return SURCHARGE_NOT_APPLIED
if any(row.surcharge_pct is not None and not row.surcharge_included for row in rows):
return SURCHARGE_APPLIED
return SURCHARGE_RATE_UNAVAILABLE
def _supply_of(value: Any) -> tuple[str, str | None]:
"""설정 한 칸을 (관급구분, 설치주체) 로 읽는다.
@@ -291,8 +314,11 @@ def build_table(
}
for row in ordered
],
# 이 표가 할증을 붙인 곳임을 못 박는다 — B09 는 다시 붙이지 않는다(㉠).
"surcharge_applied": True,
# ⚠ **깃발이 실제와 어긋나지 않게** 한다. 「붙일 자리였는데 율이 없어 못 붙였다」를
# 「붙였다」로 말하면, 나중에 율이 들어왔을 때 B09 가 한 번 더 붙인다.
"surcharge_status": _surcharge_status(ordered),
# 옛 두 갈래 깃발 — **실제로 붙었을 때만** 참이다(호환을 위해 남긴다).
"surcharge_applied": _surcharge_status(ordered) == SURCHARGE_APPLIED,
"surcharge_dataset": {
"effective_date": table.effective_date,
"source": table.source,
@@ -303,4 +329,9 @@ def build_table(
"double_count_warnings": verify_single_surcharge(unit_quantity_table),
"skipped_by_destination": skipped,
"row_count": len(ordered),
# 값의 크기가 말이 되나 — 자릿수 어긋남은 사람이 훑어야 보인다(단위별로 가른다).
"amount_spread": spread_by_unit(
[{"unit": row.unit, "amount": row.total_amount} for row in ordered],
value_key="amount",
),
}
@@ -0,0 +1,165 @@
"""콘크리트 구조물 — **관측 원단위표** 조회 (B08 일감 ⑩ · PLAN 8-6·8-8).
왜 전개식이 아니라 관측값인가
지식DB 가 못 박아 둔 사실이다 — **구조물별 표준 물량표는 품셈에 없다**
(`구조물_수량.md` 마지막 줄 · `배수공_수량.md` §2). 콘크리트 구조물의 물량은
**설계 표준도**에서 나오는데 그 표준도가 원문(법·품셈)에 없다. 그래서 옹벽·집수정처럼
치수가 표준화된 것은 **실무 설계원본에서 뽑은 관측값**이 유일한 원천이다.
두 근거가 한 표에 섞인다 — 그래서 줄마다 `basis` 를 단다
· `derived` — 저장된 치수에서 **식으로** 나온 값(돌쌓기 계열, `..._Engine_UnitQuantity`).
· `observed` — 실무 관측 원단위표에서 **규격을 맞춰 꺼낸** 값(이 모듈).
섞어 두고 근거를 안 적으면, 나중에 「이 값이 왜 이런가」를 아무도 못 되짚는다.
⚠⚠ **보간하지 않는다**
관측값은 **그 규격에서만** 맞다. `반중력식 H=2.0` 의 콘크리트 1.35 ㎥/m 를 H=1.6 으로
줄여 쓰면 틀린다 — 기초·벽 두께는 높이에 비례하지 않는다. 규격이 표에 없으면
**「원단위 미확보」로 드러낸다.** 가까운 값을 갖다 쓰는 길을 두지 않는다.
⚠ 치수를 지어내지 않는다
BOX암거는 `structures.json` 에 **벽·저판·상판 두께가 없어** 전개식조차 못 세운다.
두께를 가정하면 그 값이 콘크리트·거푸집·철근으로 **번져 나간다**. 미확보로 낸다.
⚠ 이중계상 규칙은 그대로다
㉢ 배합을 분해하지 않는다(콘크리트 ㎥·모르터 ㎥ 까지). ㉠ 할증은 자재총괄 한 곳뿐.
터파기·되메우기·잔토는 `destination: earthwork` 로 토공에 합산된다.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_structure_unit"
DATASET_PREFIX = "structure_unit_observed_"
#: 값이 어디서 왔나 — 한 표에 섞이므로 줄마다 단다.
BASIS_DERIVED = "derived" # 저장된 치수에서 식으로
BASIS_OBSERVED = "observed" # 실무 관측 원단위표에서
NOTE_UNIT_MISSING = "원단위 미확보"
def _latest_dataset_path(directory: Path | None = None) -> Path | None:
folder = directory or DATASET_DIR
if not folder.is_dir():
return None
files = sorted(folder.glob(DATASET_PREFIX + "*.json"))
return files[-1] if files else None
def _same(left: Any, right: Any) -> bool:
"""규격 한 칸 비교. 숫자는 값으로, 나머지는 글자로 **정확히** 본다.
`"800"` 과 `800` 은 같게 보되(입력 폼이 문자열을 준다), `2.0` 과 `1.6` 은 다르다 —
가까운 값을 같다고 보는 길은 두지 않는다.
"""
if isinstance(left, (int, float)) and not isinstance(left, bool):
try:
return abs(float(left) - float(right)) < 1e-9
except (TypeError, ValueError):
return False
return str(left).strip() == str(right).strip()
@dataclass
class ObservedUnitTable:
"""관측 원단위표 한 판."""
effective_date: str = ""
entries: list[dict[str, Any]] = field(default_factory=list)
sources: dict[str, Any] = field(default_factory=dict)
not_found: dict[str, Any] = field(default_factory=dict)
def find(self, type_id: str, spec: dict[str, Any]) -> dict[str, Any] | None:
"""규격이 **모두** 맞는 줄만 돌려준다. 하나라도 어긋나면 없는 것으로 본다."""
for entry in self.entries:
if entry.get("type_id") != type_id:
continue
wanted = entry.get("spec") or {}
if all(key in spec and _same(value, spec[key]) for key, value in wanted.items()):
return entry
return None
def specs_for(self, type_id: str) -> list[dict[str, Any]]:
"""그 종류로 표에 있는 규격 목록 — 「무엇이 있는지」를 화면이 보이게."""
return [
entry.get("spec") or {} for entry in self.entries if entry.get("type_id") == type_id
]
def load_observed_table(path: Path | None = None) -> ObservedUnitTable:
"""관측 원단위표를 읽는다. 파일이 없으면 **빈 표** — 전부 「미확보」로 드러난다."""
target = path or _latest_dataset_path()
if target is None or not target.is_file():
return ObservedUnitTable()
payload = json.loads(target.read_text(encoding="utf-8"))
return ObservedUnitTable(
effective_date=str(payload.get("effective_date") or ""),
entries=list(payload.get("entries") or []),
sources=payload.get("sources") or {},
not_found=payload.get("not_found") or {},
)
def scale_for(entry: dict[str, Any], structure: dict[str, Any]) -> tuple[float, str]:
"""관측값에 곱할 수 — 단위가 `m` 면 연장, `㎡` 면 면적, `개소` 면 1.
⚠ **규격을 늘리는 것이 아니라 개수를 세는 것**이다. `H=2.0 옹벽 10m` 는 같은 단면이
10m 이어진 것이라 곱해도 되지만, `H=1.6` 으로 바꾸는 것은 단면이 달라지므로 안 된다.
"""
options = structure.get("options") or {}
unit = str(entry.get("unit") or "개소")
if unit == "m":
length = options.get("length_m")
if length is None:
start, end = structure.get("start_m"), structure.get("end_m")
length = (
abs(float(end) - float(start)) if start is not None and end is not None else 0.0
)
return float(length or 0.0), f"연장 {float(length or 0.0):g} m"
if unit == "":
width = options.get("ford_width_m")
length = options.get("length_m") or 0.0
area = float(width or 0.0) * float(length or 0.0)
return area, f"면적 {area:g}"
return 1.0, "1 개소"
def expand_observed(
type_id: str,
spec: dict[str, Any],
structure: dict[str, Any],
table: ObservedUnitTable | None = None,
) -> tuple[list[dict[str, Any]], list[str]]:
"""(성분 목록, 알림). 규격이 표에 없으면 **빈 목록 + 미확보 알림**을 낸다."""
found = (table or load_observed_table()).find(type_id, spec)
if found is None:
known = (table or load_observed_table()).specs_for(type_id)
detail = f" — 표에 있는 규격: {known}" if known else ""
return [], [f"{NOTE_UNIT_MISSING} ({type_id} {spec}){detail}"]
scale, scale_note = scale_for(found, structure)
if scale <= 0:
return [], [f"{NOTE_UNIT_MISSING} — 곱할 연장·면적이 0 ({type_id})"]
source_key = str(found.get("source") or "")
components: list[dict[str, Any]] = []
for item in found.get("components") or []:
note = str(item.get("basis_note") or "")
components.append(
{
"name": item["name"],
"unit": item["unit"],
"amount": float(item["amount"]) * scale,
"destination": item.get("destination") or "material",
# 근거를 값 옆에 붙인다 — 관측값임을 화면·인계에서 바로 알아야 한다.
"basis": f"관측 원단위 {item['amount']:g}/{found.get('unit')} × {scale_note}"
+ (f" ({note})" if note else ""),
"basis_kind": BASIS_OBSERVED,
"source": source_key,
}
)
return components, [f"관측 원단위 적용 — {found.get('source_note') or source_key}"]
@@ -0,0 +1,138 @@
"""준비공·사방공 — **자리를 만들되 없는 값을 지어내지 않는다** (B08 일감 ⑪ · PLAN 8-3).
8-3 대응표에서 ❌ 로 남아 있던 둘이다. 여기서 하는 일은 **줄을 세우고, 설 수 있는 줄은
값을 채우고, 못 서는 줄은 왜 못 서는지 적는 것**이다. 빈 표를 내면 「빠뜨린 것」과
「원래 없는 것」이 구별되지 않는다.
⚠⚠ 지장목제거와 겹치지 않는다 (이중계상)
벌목·지장목제거는 **이미 토공집계의 사면 계열로 서 있다**(`tree_removal_*` × 반영률).
여기서 또 세우면 같은 나무를 두 번 벤다. 그래서 준비공의 벌목 줄은 **값을 내지 않고
「토공집계 지장목제거로 이미 섬」이라고 가리키기만** 한다.
⚠ 값이 없는 줄의 사유를 적는다
· 표토제거(9-15) — 면적은 사면적에서 나오나 **두께·대상 구간이 설계로 안 정해져 있다**.
· 제근·뿌리다듬기(9-20~21) — 단위가 **「개」(그루 수)**인데 입목 본수를 우리가 안 든다.
· 규준틀(11-2) — **개소** 산정 기준(구조물·절성토 구간별 몇 개소)이 안 정해져 있다.
⚠ 사방공은 이 노선에 실물이 없으면 「해당 없음」이다
있는 것처럼 0 을 적지 않는다. 구조물 목록에 사방 시설이 서면 그때 값이 선다.
"""
from __future__ import annotations
from typing import Any, Iterable
#: 사방 시설로 보는 구조물 종류 — **레지스트리의 실제 `type_id` 를 쓴다**(D 그룹 + 흙막이).
#: 목록에 없으면 그 노선에 사방공이 **없는** 것이다. 이름을 지어내면 영영 안 걸린다.
EROSION_CONTROL_TYPES = frozenset(
{
"erosion_check", # 골막이
"bed_sill", # 바닥막이
"check_dam_small", # 소형사방댐(복합형)
"revetment", # 기슭막이
"soil_guard", # 흙막이
}
)
STATUS_COUNTED_ELSEWHERE = "다른 표에서 이미 섬"
STATUS_PENDING = "값을 낼 근거가 없음"
STATUS_NOT_APPLICABLE = "해당 없음"
STATUS_READY = "값 있음"
def preparation_rows(slope_totals: dict[str, float] | None = None) -> list[dict[str, Any]]:
"""준비공 줄 — 값이 서는 것과 안 서는 것을 **한 목록에** 낸다."""
slope = slope_totals or {}
tree_area = float(slope.get("tree_removal_fill", 0.0)) + float(
slope.get("tree_removal_cut", 0.0)
)
return [
{
"group": "준비공",
"item": "벌목·지장목제거",
"unit": "",
"amount": None,
"status": STATUS_COUNTED_ELSEWHERE,
# ⚠ 값을 여기서 또 내면 같은 나무를 두 번 벤다. 참고로 면적만 보인다.
"reference_amount": tree_area,
"reason": "토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상",
"work_item_code": None,
},
{
"group": "준비공",
"item": "표토제거",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": "면적은 사면적에서 나오나 **두께·대상 구간**이 설계로 안 정해져 있음 (품셈 9-15)",
"work_item_code": "FP-09-15",
},
{
"group": "준비공",
"item": "제근·뿌리다듬기",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": "단위가 「개」(그루 수)인데 입목 본수를 들고 있지 않음 (품셈 9-20~21)",
"work_item_code": "FP-09-21",
},
{
"group": "준비공",
"item": "규준틀",
"unit": "개소",
"amount": None,
"status": STATUS_PENDING,
"reason": "개소 산정 기준(구조물·절성토 구간별 몇 개소)이 안 정해져 있음 (품셈 11-2)",
"work_item_code": "FP-11-02",
},
]
def erosion_rows(structures: Iterable[dict[str, Any]] = ()) -> list[dict[str, Any]]:
"""사방공 줄 — 이 노선에 사방 시설이 **있을 때만** 값이 선다."""
found = sorted(
{
str(item.get("type_id"))
for item in structures
if str(item.get("type_id")) in EROSION_CONTROL_TYPES
}
)
if not found:
return [
{
"group": "사방공",
"item": "사방 시설",
"unit": "",
"amount": None,
"status": STATUS_NOT_APPLICABLE,
"reason": "이 노선에 사방 시설이 배치돼 있지 않음 — 있는 것처럼 0 을 적지 않음",
"work_item_code": None,
}
]
return [
{
"group": "사방공",
"item": type_id,
"unit": "개소",
"amount": None,
"status": STATUS_PENDING,
"reason": "구조물 원단위가 아직 없음 — 전개식·관측값 모두 미확보",
"work_item_code": None,
}
for type_id in found
]
def build_table(
slope_totals: dict[str, float] | None = None,
structures: Iterable[dict[str, Any]] = (),
) -> dict[str, Any]:
"""화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**"""
rows = preparation_rows(slope_totals) + erosion_rows(structures)
return {
"columns": ["구분", "공종", "단위", "수량", "상태", "사유"],
"rows": rows,
"ready_count": sum(1 for row in rows if row["status"] == STATUS_READY),
"pending_count": sum(1 for row in rows if row["status"] == STATUS_PENDING),
"row_count": len(rows),
}
@@ -34,6 +34,17 @@ import math
from dataclasses import dataclass, field
from typing import Any, Iterable
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import (
BASIS_DERIVED,
BASIS_OBSERVED,
ObservedUnitTable,
expand_observed,
load_observed_table,
)
from B08_Quantity.B08_Quantity_Engine_Formwork import annotate as annotate_formwork
from B08_Quantity.B08_Quantity_Engine_Formwork import shoring_status
from common_util.common_util_quantity_spread import spread_by_unit
# ── 계수표 — 식에 박지 않고 여기서 고른다 ─────────────────────────────
# 돌 뒷길이(㎝)별 원단위. 출처: `original/실무문서/_원단위라이브러리_울진소광.md` 「돌뒷길이별 원단위표」.
# ⚠ 60㎝ 돌중량은 원본이 비어 있다 — 지어내지 않고 None 으로 둔다(PLAN 8-8 ㉮).
@@ -98,6 +109,10 @@ class Component:
amount: float
destination: str
basis: str = ""
# ⚠ 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표).
# 두 근거가 한 표에 섞이므로 줄마다 단다. 안 적으면 나중에 못 되짚는다.
basis_kind: str = BASIS_DERIVED
source: str = ""
@dataclass(slots=True)
@@ -249,6 +264,16 @@ def stone_masonry(
# 구조물 종류 → 전개식. 없는 종류는 전개하지 않고 이름만 남긴다(지어내지 않는다).
# ⚠ **관측 원단위표로 가는 종류** — 치수가 저장돼 있지 않아 전개식을 못 세우는 것들이다.
# 값의 키(규격)를 저장 제원의 어느 칸에서 읽는지 여기 적는다. 표에 규격이 없으면
# 「원단위 미확보」로 드러난다 — 가까운 값을 갖다 쓰지 않는다.
OBSERVED_SPEC_KEYS: dict[str, tuple[str, ...]] = {
"retaining_wall": ("form", "height_m"),
"ford_pavement": ("thickness_cm",),
# 배수관의 유입부 집수정은 관 자체와 **다른 줄**이다 — 관은 관대로 서고 집수정이 따로 선다.
"pipe_inlet_basin": ("inlet_basin_form", "inlet_basin_material", "pipe_diameter_mm"),
}
EXPANDERS = {
"masonry_wet": lambda h, l, o: stone_masonry(h, l, o, wet=True),
"masonry_dry": lambda h, l, o: stone_masonry(h, l, o, wet=False),
@@ -256,7 +281,55 @@ EXPANDERS = {
}
def expand(structure: dict[str, Any], names: dict[str, str] | None = None) -> StructureQuantity:
#: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다
#: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다.
ATTACHMENTS: dict[str, tuple[tuple[str, str, str], ...]] = {
# (붙는 종류, 그것이 있는지 보는 옵션 칸, 줄 이름 꼬리)
"pipe": (("pipe_inlet_basin", "inlet_basin_form", "유입부 집수정"),),
}
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
def _observed_components(
type_id: str,
structure: dict[str, Any],
observed: ObservedUnitTable | None,
) -> tuple[list[dict[str, Any]], list[str]]:
"""관측 원단위표에서 꺼낸다. 규격 키가 정해져 있지 않은 종류는 건드리지 않는다."""
keys = OBSERVED_SPEC_KEYS.get(type_id)
if keys is None:
return [], []
options = structure.get("options") or {}
spec = {key: options[key] for key in keys if options.get(key) is not None}
if not spec:
return [], [f"{type_id} 규격이 비어 있음 — 관측 원단위를 고를 수 없음"]
return expand_observed(type_id, spec, structure, observed)
def expand(
structure: dict[str, Any],
names: dict[str, str] | None = None,
observed: ObservedUnitTable | None = None,
) -> StructureQuantity:
"""구조물 하나를 전개한다. 치수는 저장된 제원에서만 읽는다(치수 두 벌 금지)."""
type_id = str(structure.get("type_id") or "")
options = structure.get("options") or {}
@@ -264,10 +337,15 @@ def expand(structure: dict[str, Any], names: dict[str, str] | None = None) -> St
end = _num(structure.get("end_m"))
length = _num(options.get("length_m")) or abs(end - start)
height = _num(options.get("height_m"))
label = (names or {}).get(type_id, type_id)
if structure.get("attachment_label"):
# 「배수관 · 유입부 집수정」처럼 어디에 딸린 줄인지 이름에 남긴다.
parent = (names or {}).get(str(structure.get("attachment_parent_type") or ""), "")
label = f"{parent or label} · {structure['attachment_label']}".strip(" ·")
result = StructureQuantity(
structure_id=structure.get("structure_id"),
type_id=type_id,
name=(names or {}).get(type_id, type_id),
name=label,
length_m=length,
height_m=height,
start_m=start if structure.get("start_m") is not None else None,
@@ -275,6 +353,12 @@ def expand(structure: dict[str, Any], names: dict[str, str] | None = None) -> St
)
expander = EXPANDERS.get(type_id)
if expander is None:
# 전개식이 없으면 **관측 원단위표**를 본다(치수가 저장돼 있지 않은 종류).
components, notes = _observed_components(type_id, structure, observed)
if components or notes:
result.components = [Component(**item) for item in components]
result.notes.extend(notes)
return result
result.notes.append(f"'{type_id}' 전개식이 아직 없음 — 물량을 내지 않음")
return result
result.components, notes = expander(height, length, options)
@@ -300,7 +384,13 @@ def build_table(
structures: Iterable[dict[str, Any]], names: dict[str, str] | None = None
) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다."""
quantities = [expand(item, names) for item in structures]
observed = load_observed_table()
# 딸린 줄(배수관의 유입부 집수정 등)을 원본 뒤에 세운다 — 한 줄로 합치지 않는다.
expanded_inputs: list[dict[str, Any]] = []
for item in structures:
expanded_inputs.append(item)
expanded_inputs.extend(attachments_of(item))
quantities = [expand(item, names, observed) for item in expanded_inputs]
violations = verify_no_mix_components(quantities)
totals: dict[str, dict[str, Any]] = {}
@@ -318,8 +408,7 @@ def build_table(
)
entry["amount"] += component.amount
return {
"structures": [
payload_structures = [
{
"structure_id": item.structure_id,
"type_id": item.type_id,
@@ -336,15 +425,27 @@ def build_table(
"amount": component.amount,
"destination": component.destination,
"basis": component.basis,
"basis_kind": component.basis_kind,
"source": component.source,
}
for component in item.components
],
}
for item in quantities
],
]
# 거푸집 줄에 **몇 회짜리인지**를 달아 준다. 횟수별 재료 환산은 하지 않는다(B09 몫).
formwork_notes, formwork_missing = annotate_formwork(payload_structures)
return {
"structures": payload_structures,
"formwork_notes": formwork_notes,
"formwork_reuse_missing": formwork_missing,
# 동바리 — 대상이 없으면 0 이 아니라 「없음」이라고 말한다.
"shoring": shoring_status(),
"totals": sorted(totals.values(), key=lambda entry: entry["name"]),
# 할증 전 값임을 응답에 못 박는다 — 자재총괄이 한 번만 붙인다(㉠).
"surcharge_applied": False,
"mix_components_found": violations,
"structure_count": len(quantities),
"amount_spread": spread_by_unit(totals.values(), value_key="amount"),
}
+54 -3
View File
@@ -31,10 +31,12 @@ 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_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_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_SlopeArea import build_table as build_slope_table
from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes
from common_util.common_util_project_settings import (
ROCK_METHODS,
application_ratio,
quantity_settings,
rock_classes,
@@ -93,12 +95,30 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
},
)
)
# 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨).
table["preparation"] = build_preparation_table(
slope.get("totals") or {}, await _route_structures(project_id)
)
table["settings"] = settings
table["project_root_known"] = project_root is not None
table["route_id"] = route_id
return JSONResponse(content=table)
async def _route_structures(project_id: UUID) -> list[dict[str, Any]]:
"""배치된 구조물 목록 — 사방 시설이 있는지 보려는 것뿐이다. 없으면 빈 목록."""
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
root = resolve_stored_project_path(stored_path)
from B05_Profile.B05_Profile_Structures_Repository import load_structures
_revision, items = load_structures(root)
return [item.model_dump() for item in items]
except Exception:
logger.warning("B08 준비공 — 구조물 목록을 못 읽음: project_id=%s", project_id)
return []
async def _project_settings(project_id: UUID) -> tuple[dict[str, Any], str | None]:
"""프로젝트 설정을 읽는다. 경로를 못 찾아도 기본값으로 화면은 선다."""
try:
@@ -113,14 +133,22 @@ async def _project_settings(project_id: UUID) -> tuple[dict[str, Any], str | Non
async def _stored_haul_plan(project_id: UUID, route_id: int) -> dict[str, Any] | None:
"""정본에 남은 운반계획. [확정]을 아직 안 돌렸으면 없다."""
"""정본에 남은 **배분**(`mass_haul.haul_plan`). [확정]을 아직 안 돌렸으면 없다.
⚠ 정본에 저장되는 것은 유토곡선 한 벌(`mass_haul`)이고 **배분은 그 안의 `haul_plan`** 이다.
바깥 껍데기를 그대로 넘기면 `blocks` 를 못 찾아 **운반 표가 영영 0줄**이 된다 —
[확정] 전에는 어차피 빈 표라 화면에서 티가 안 나던 자리다(2026-09-07 실증에서 잡음).
"""
try:
row = await run_with_connection(get_longitudinal_section, project_id, route_id)
except Exception:
logger.exception("B08 운반계획 조회 실패: route_id=%s", route_id)
return None
data = (row or {}).get("data") or {}
plan = data.get("mass_haul") if isinstance(data, dict) else None
mass_haul = data.get("mass_haul") if isinstance(data, dict) else None
if not isinstance(mass_haul, dict):
return None
plan = mass_haul.get("haul_plan")
return plan if isinstance(plan, dict) and plan else None
@@ -130,7 +158,12 @@ class QuantitySettingsBody(BaseModel):
rock_class_set: str | None = None
rock_classes: list[str] | None = None
rock_ratios_pct: dict[str, float] | None = None
# 갈래별 시공법 — 값은 "ripping"·"blasting". 안 정한 갈래는 보내지 않는다.
rock_methods: dict[str, str] | None = None
application_ratios_pct: dict[str, float] | None = None
# 자재별 관급/사급 — `{자재명: {"supply": …, "install_by": …}}`.
# 표 안에서 줄마다 고른 값이 여기로 온다(2026-09-07 확정).
material_supply: dict[str, Any] | None = None
@router.put("/{project_id}/quantity/settings")
@@ -150,8 +183,20 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
values = {key: value for key, value in body.model_dump().items() if value is not None}
if "rock_methods" in values:
# 「안 정함」(빈 값)은 저장하지 않는다 — 정한 것과 구별이 안 된다. 통째로 갈아 끼우므로
# 여기서 버리면 그 갈래는 미지정으로 돌아간다.
values["rock_methods"] = {
name: method
for name, method in values["rock_methods"].items()
if method in ROCK_METHODS
}
try:
saved = await asyncio.to_thread(save_section, root, "quantity", values)
# ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면
# 「안 정함」으로 되돌아가지 않는다(2026-09-07 화면에서 걸린 자리).
saved = await asyncio.to_thread(
_save_quantity, root, values, ("rock_methods", "material_supply")
)
except Exception:
logger.exception("B08 설정 저장 실패(쓰기): project_id=%s", project_id)
return JSONResponse(
@@ -161,6 +206,12 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
return JSONResponse(content={"status": "success", "quantity": saved.get("quantity") or {}})
def _save_quantity(
root: str, values: dict[str, Any], replace_keys: tuple[str, ...]
) -> dict[str, Any]:
return save_section(root, "quantity", values, replace_keys=replace_keys)
@router.get("/{project_id}/quantity/earthwork-table")
async def get_earthwork_table_for_current_route(project_id: UUID) -> JSONResponse:
"""경로를 안 주면 워크플로가 보고 있는 경로로 낸다 — 화면이 route_id 를 모를 때 쓴다."""
+6 -1
View File
@@ -28,7 +28,11 @@ from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, summarize
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
from common_util.common_util_project_settings import quantity_settings, rock_classes
from common_util.common_util_project_settings import (
quantity_settings,
rock_classes,
rock_method,
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import run_with_connection
@@ -143,6 +147,7 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
material_table=material_table,
ground_class_set=settings.get("rock_class_set"),
ground_classes=rock_classes(settings),
ground_methods={name: rock_method(settings, name) for name in rock_classes(settings)},
)
handoff["summary"] = summarize(handoff)
handoff["skipped_structures"] = skipped
@@ -59,6 +59,10 @@ export interface QuantitySettings {
rock_classes?: string[];
rock_ratios_pct?: Record<string, number>;
application_ratios_pct?: Record<string, number>;
/** 갈래별 시공법 — `"ripping"`·`"blasting"`. 안 정한 갈래는 아예 없다. */
rock_methods?: Record<string, string>;
/** 자재별 관급/사급 — 표 안에서 줄마다 고른 값. */
material_supply?: Record<string, { supply: string; install_by: string | null }>;
}
export interface EarthworkTable {
@@ -188,10 +192,6 @@ const PAIR_LABELS = ["단면적", "입 적"];
const flatColumns = (): Column[] => GROUPS.flatMap((g) => g.sub.flatMap((s) => s.cols));
/** 사면 열 개수 — 계열마다 (거리, 면적) 두 칸. */
const slopeColumnCount = (): number =>
SLOPE_GROUPS.reduce((n, group) => n + group.faces.length * 2, 0);
/** 측점 표기 — `20` → `NO.1`, `25` → `NO.1+5`. 실무 토적표가 이 모양이다. */
function stationLabel(chainage: number, interval = 20): string {
const no = Math.floor(chainage / interval);
@@ -13,6 +13,25 @@ const STYLE_ID = "b08-earthwork-grid-style";
const CSS = `
.b08-grid { display: flex; flex-direction: column; gap: 8px; min-width: 0; height: 100%; }
/* 표 안에서 고르는 칸 — 관급/사급처럼 **줄마다 갈리는 값**을 여기서 정한다. */
.b08-grid__select {
width: 100%;
min-width: 5.5rem;
padding: 0.15rem 0.25rem;
font: inherit;
color: var(--color-text);
background: var(--color-surface-raised);
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.4));
border-radius: 3px;
}
.b08-grid__select:disabled {
opacity: 0.45; /* 사급 줄의 설치 주체 — 뜻이 없으므로 흐리게 둔다 */
}
/* 만진 줄은 표시가 남는다 — 무엇을 바꿨는지 보여야 한다. */
.b08-grid__table td.is-changed {
box-shadow: inset 2px 0 0 var(--color-accent, #6c8ebf);
}
.b08-grid__caption {
margin: 0;
font-size: 12px;
+145 -2
View File
@@ -33,6 +33,7 @@ export interface MaterialTable {
missing_rate_materials: string[];
missing_supply_materials: string[];
missing_install_by_materials: string[];
amount_spread: Record<string, { min: number; median: number; max: number; count: number }>;
double_count_warnings: string[];
skipped_by_destination: Record<string, number>;
row_count: number;
@@ -51,6 +52,12 @@ export interface UnitQuantityStructure {
amount: number;
destination: string;
basis: string;
/** 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표). */
basis_kind?: string;
source?: string;
/** 거푸집 줄만 — 몇 회짜리인가(품셈 1-7-1). 횟수별 재료 환산은 B09 몫이다. */
reuse_count?: number | null;
reuse_note?: string;
}[];
}
@@ -67,6 +74,13 @@ export interface MaterialResponse {
structure_count: number;
}
/** 거푸집·동바리 안내에 쓰는 값. */
export interface FormworkInfo {
formwork_notes?: string[];
formwork_reuse_missing?: string[];
shoring?: { applicable: boolean; reason: string; pending_types: string[] };
}
/** 성분이 어디로 가는지 — 화면에서도 보이게 한다. 규칙이 코드에만 있으면 잊힌다. */
const DESTINATION_LABELS: Record<string, string> = {
earthwork: "토공 합산",
@@ -110,8 +124,78 @@ function warning(title: string, items: string[]): HTMLElement | null {
return element;
}
/** 관급/사급 고르는 칸의 보기. **값은 영문 키, 표기는 한글**(B09 와 같은 낱말). */
const SUPPLY_OPTIONS = [
{ value: "unknown", label: "미분류" },
{ value: "contractor_supplied", label: "사급" },
{ value: "owner_supplied", label: "관급" },
];
const INSTALL_BY_OPTIONS = [
{ value: "", label: "미지정" },
{ value: "contractor", label: "도급자설치" },
{ value: "owner", label: "관 직접설치" },
];
export interface SupplyChoice {
supply: string;
install_by: string | null;
}
export interface MaterialGridOptions {
/** 저장 전 변경분 — 고른 값은 여기 쌓이고 [저장]에서만 정본으로 간다. */
choices: Record<string, SupplyChoice>;
onChange: () => void;
}
/** 표 안의 고르는 칸. 바꾼 줄은 **표시가 남는다** — 무엇을 만졌는지 보여야 한다. */
function choiceCell(
value: string,
options: { value: string; label: string }[],
disabled: boolean,
onChange: (value: string) => void,
): HTMLTableCellElement {
const td = document.createElement("td");
const select = document.createElement("select");
select.className = "b08-grid__select";
for (const option of options) {
const element = document.createElement("option");
element.value = option.value;
element.textContent = option.label;
select.append(element);
}
select.value = value;
select.disabled = disabled;
select.addEventListener("change", () => {
onChange(select.value);
td.classList.add("is-changed");
});
td.append(select);
return td;
}
/** 값의 크기 요약 — 자릿수가 어긋난 것은 사람이 훑어야 보인다. */
function spreadLine(spread: MaterialTable["amount_spread"], title: string): HTMLElement | null {
const units = Object.keys(spread || {});
if (!units.length) return null;
const element = document.createElement("p");
element.className = "b08-grid__caption";
element.textContent =
title +
" " +
units
.map((unit) => {
const s = spread[unit];
return `${unit} 최소 ${num(s.min, 2)} · 중앙 ${num(s.median, 2)} · 최대 ${num(s.max, 2)}`;
})
.join(" / ");
return element;
}
/** 자재총괄표 — 할증이 붙는 유일한 자리. */
export function renderMaterialGrid(table: MaterialTable): HTMLElement {
export function renderMaterialGrid(
table: MaterialTable,
options?: MaterialGridOptions,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b08-grid";
@@ -121,6 +205,9 @@ export function renderMaterialGrid(table: MaterialTable): HTMLElement {
caption.textContent = `자재 ${table.row_count}종 · 할증률 ${edition} 판 적용 · 금액은 원가계산(B09)에서`;
wrap.append(caption);
const spread = spreadLine(table.amount_spread, "물량 크기:");
if (spread) wrap.append(spread);
for (const notice of [
warning("⚠ 중복 할증 위험", table.double_count_warnings),
warning("할증률 미확보", table.missing_rate_materials),
@@ -153,8 +240,46 @@ export function renderMaterialGrid(table: MaterialTable): HTMLElement {
// 미확보는 빈칸이 아니라 「-」 — 빈칸이면 0 % 로 오해된다.
tr.append(textCell(row.surcharge_pct === null ? "" : num(row.surcharge_pct, 0)));
tr.append(textCell(num(row.total_amount, 2)));
if (options) {
// 관급/사급은 **자재마다 갈리는 발주 결정**이라 줄에서 고른다(2026-09-07 확정).
const chosen = options.choices[row.name] ?? {
supply: row.supply,
install_by: row.install_by,
};
const installCell = choiceCell(
chosen.install_by ?? "",
INSTALL_BY_OPTIONS,
chosen.supply !== "owner_supplied", // 관급 줄에만 고를 수 있다
(value) => {
const current = options.choices[row.name] ?? chosen;
options.choices[row.name] = { supply: current.supply, install_by: value || null };
options.onChange();
},
);
tr.append(
choiceCell(chosen.supply, SUPPLY_OPTIONS, false, (value) => {
const current = options.choices[row.name] ?? chosen;
const next = {
// 사급으로 되돌리면 설치 주체는 뜻을 잃으므로 비운다.
supply: value,
install_by: value === "owner_supplied" ? (current.install_by ?? null) : null,
};
options.choices[row.name] = next;
// ⚠ 표를 다시 그리지 않으므로 **여기서 바로 열고 닫는다** — 안 그러면 관급을 골라도
// 설치 주체 칸이 잠긴 채 남아 사용자가 못 정한다(만들고 화면에서 걸린 자리).
const select = installCell.querySelector("select") as HTMLSelectElement | null;
if (select) {
select.disabled = value !== "owner_supplied";
select.value = next.install_by ?? "";
}
options.onChange();
}),
);
tr.append(installCell);
} else {
tr.append(textCell(row.supply_label));
tr.append(textCell(row.install_by_label));
}
tr.append(textCell(row.note, "b08-grid__note"));
body.append(tr);
}
@@ -182,6 +307,20 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement
const skipped = warning("건너뛴 구조물", response.skipped_structures);
if (skipped) wrap.append(skipped);
// 거푸집 사용횟수 — 값이 아니라 **몇 회짜리인지**를 알려 주는 자리(품셈 1-7-1).
const info = unit as unknown as FormworkInfo;
const reuse = warning("거푸집 사용횟수", info.formwork_notes ?? []);
if (reuse) wrap.append(reuse);
const reuseMissing = warning("사용횟수 미확보", info.formwork_reuse_missing ?? []);
if (reuseMissing) wrap.append(reuseMissing);
if (info.shoring && !info.shoring.applicable) {
// 「없음」을 0 으로 적지 않는다 — 대상이 없는 것과 값이 0 인 것은 다르다.
const line = document.createElement("p");
line.className = "b08-grid__caption";
line.textContent = `동바리: 대상 없음 — ${info.shoring.reason.replace(/\*\*/g, "")}`;
wrap.append(line);
}
if (!unit.structures.length) {
const empty = document.createElement("p");
empty.className = "b08-quantity__message";
@@ -194,7 +333,7 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement
scroller.className = "b08-grid__scroll";
const element = document.createElement("table");
element.className = "b08-grid__table b08-grid__table--summary";
element.append(headRow(["구조물", "규격", "성분", "단위", "수량", "갈 곳", "근거"]));
element.append(headRow(["구조물", "규격", "성분", "단위", "수량", "갈 곳", "근거", "출처"]));
const body = document.createElement("tbody");
for (const structure of unit.structures) {
@@ -221,6 +360,10 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement
tr.append(textCell(num(component.amount, 3)));
tr.append(textCell(DESTINATION_LABELS[component.destination] ?? component.destination));
tr.append(textCell(component.basis, "b08-grid__note"));
// ⚠ 식에서 나온 값과 실무 관측값이 한 표에 섞인다 — 어느 쪽인지 화면에서 보여야
// 나중에 「이 값이 왜 이런가」를 되짚을 수 있다.
const kind = component.basis_kind === "observed" ? "실무 관측" : "치수 전개";
tr.append(textCell(component.reuse_count ? `${kind} · ${component.reuse_count}` : kind));
body.append(tr);
}
}
+93 -5
View File
@@ -19,7 +19,12 @@ import {
} from "../A00_Common/b_workflow_nav";
import { renderEarthworkGrid, type EarthworkTable } from "./B08_Quantity_UI_EarthworkGrid";
import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style";
import { renderHaulGrid, renderSummaryGrid } from "./B08_Quantity_UI_SummaryGrid";
import {
renderHaulGrid,
renderPreparationGrid,
renderSummaryGrid,
type PreparationTable,
} from "./B08_Quantity_UI_SummaryGrid";
import {
renderMaterialGrid,
renderUnitQuantityGrid,
@@ -74,6 +79,10 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
rock_class_set: draft.rock_class_set ?? null,
rock_ratios_pct: draft.rock_ratios_pct,
application_ratios_pct: draft.application_ratios_pct,
// ⚠ 「안 정함」으로 되돌린 갈래까지 **통째로** 보낸다. 정한 것만 보내면 서버가
// 병합해 옛 값이 남아 되돌릴 길이 없다(화면에서 걸린 자리). 빈 값은 서버가 버린다.
rock_methods: draft.rock_methods,
material_supply: draft.material_supply,
}),
},
);
@@ -124,11 +133,47 @@ function numberField(label: string, value: number, onInput: (value: number) => v
return row;
}
/** 고르는 칸. 첫 보기는 **「안 정함」**이고 그것이 기본이다 — 찍으면 값이 조용히 틀린다. */
function selectField(
label: string,
value: string,
options: { value: string; label: string }[],
onChange: (value: string) => void,
): HTMLElement {
const row = document.createElement("label");
row.className = "b08-quantity__field";
const name = document.createElement("span");
name.textContent = label;
const select = document.createElement("select");
select.className = "b08-quantity__input";
for (const option of options) {
const element = document.createElement("option");
element.value = option.value;
element.textContent = option.label;
select.append(element);
}
select.value = value;
// 자동저장은 만들지 않는다 — 고른 값은 캐시에만 남는다(CLAUDE.md 5장).
select.addEventListener("change", () => onChange(select.value));
row.append(name, select);
return row;
}
/** 자재 한 줄의 관급/사급. `install_by` 는 **관급 줄에만** 뜻이 있다. */
export interface SupplyChoice {
supply: string;
install_by: string | null;
}
/** 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다. */
interface DraftSettings {
rock_class_set?: string;
rock_ratios_pct: Record<string, number>;
application_ratios_pct: Record<string, number>;
// 갈래별 시공법 — `""` 는 「안 정함」이고 저장에서 빠진다.
rock_methods: Record<string, string>;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
material_supply: Record<string, SupplyChoice>;
dirty: boolean;
}
@@ -154,10 +199,16 @@ function buildQuantitySidePanel(
}
// ── 지반 구성비 — 갈래 수는 프로젝트 세트가 정한다(코드에 안 박음, PLAN 8-13) ──
const classes = table?.summary?.rock_classes ?? [];
const classes = [...(table?.summary?.rock_classes ?? [])];
// ⚠ 비율을 아직 안 넣었으면 집계가 **「암」 한 줄**로 나온다(갈래로 안 갈림). 그 줄에도
// 시공법을 정할 수 있어야 공종이 선다 — 그때만 칸을 하나 더 낸다.
const hasRockFallback = (table?.summary?.rows ?? []).some((row) => row.item === "암");
if (hasRockFallback && !classes.includes("암")) classes.push("암");
if (classes.length) {
panel.append(field(L("B08_Quantity_Side_RockRatios"), ""));
for (const name of classes) {
// 「암」은 비율을 넣으면 사라지는 되메움 줄이라 비율 칸을 두지 않는다.
if (name !== "암") {
panel.append(
numberField(name, draft.rock_ratios_pct[name] ?? 0, (value) => {
draft.rock_ratios_pct[name] = value;
@@ -165,6 +216,26 @@ function buildQuantitySidePanel(
}),
);
}
// ⚠ 암 갈래는 **시공법까지 정해야** 공종이 갈린다 — 품셈이 긁어내기(암절취)와
// 터뜨리기(발파암)를 다른 공종으로 두기 때문이다. 「토사」에는 안 붙인다.
if (name !== "토사") {
panel.append(
selectField(
` ${name} ${L("B08_Quantity_Side_Method_Label")}`,
draft.rock_methods[name] ?? "",
[
{ value: "", label: L("B08_Quantity_Method_Unset") },
{ value: "ripping", label: L("B08_Quantity_Method_Ripping") },
{ value: "blasting", label: L("B08_Quantity_Method_Blasting") },
],
(value) => {
draft.rock_methods[name] = value;
draft.dirty = true;
},
),
);
}
}
}
// ── 반영률 — 기본 100 %. 실무 관측 80/50/80 은 기본값이 아니다(PLAN 8-11) ──
@@ -183,7 +254,7 @@ function buildQuantitySidePanel(
const saveButton = createButton({
label: L("B08_Quantity_Btn_Save"),
variant: "outlined",
variant: "ghost",
onClick: () => {
if (!projectId) {
showToast(L("B08_Quantity_Save_Failed"), "error");
@@ -240,6 +311,7 @@ function buildQuantityBody(
table: EarthworkTable | null,
failed: boolean,
material: MaterialResponse | null,
draft: DraftSettings,
): HTMLElement {
const body = document.createElement("div");
body.className = "b08-quantity__body";
@@ -279,6 +351,15 @@ function buildQuantityBody(
? renderHaulGrid(table.haul, Boolean(table.haul_available))
: message(L("B08_Quantity_Haul_Missing")),
},
{
label: L("B08_Quantity_Tab_Preparation"),
build: () => {
const preparation = (table as unknown as { preparation?: PreparationTable }).preparation;
return preparation
? renderPreparationGrid(preparation)
: message(L("B08_Quantity_Grid_Empty"));
},
},
{
label: L("B08_Quantity_Tab_UnitQuantity"),
build: () =>
@@ -288,7 +369,12 @@ function buildQuantityBody(
label: L("B08_Quantity_Tab_Material"),
build: () =>
material
? renderMaterialGrid(material.material)
? renderMaterialGrid(material.material, {
choices: draft.material_supply,
onChange: () => {
draft.dirty = true;
},
})
: message(L("B08_Quantity_Material_Failed")),
},
];
@@ -344,6 +430,8 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
rock_class_set: stored.rock_class_set,
rock_ratios_pct: { ...(stored.rock_ratios_pct ?? {}) },
application_ratios_pct: { ...(stored.application_ratios_pct ?? {}) },
rock_methods: { ...((stored.rock_methods ?? {}) as Record<string, string>) },
material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) },
dirty: false,
};
const reload = (): void => {
@@ -372,7 +460,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
steps: workflowSteps(),
activeStep: 5,
leftPanel: buildQuantitySidePanel(projectId, table, draft, reload),
mainContent: buildQuantityBody(table, failed, material),
mainContent: buildQuantityBody(table, failed, material, draft),
stages: workflowState?.stages,
currentStage: workflowState?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
@@ -188,3 +188,74 @@ export function renderHaulGrid(table: HaulTable, available: boolean): HTMLElemen
wrap.append(scroller);
return wrap;
}
export interface PreparationRow {
group: string;
item: string;
unit: string;
amount: number | null;
status: string;
reason: string;
reference_amount?: number;
work_item_code: string | null;
}
export interface PreparationTable {
columns: string[];
rows: PreparationRow[];
pending_count: number;
row_count: number;
}
/** 준비공·사방공 — **못 서는 줄도 보인다.**
*
* 빈 표를 내면 「빠뜨린 것」과 「원래 없는 것」이 구별되지 않는다. 그래서 값이 없는 줄도
* 상태와 사유를 달아 그대로 세운다.
*/
export function renderPreparationGrid(table: PreparationTable): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b08-grid";
const caption = document.createElement("p");
caption.className = "b08-grid__caption";
caption.textContent = `${table.row_count}줄 · 값을 낼 근거가 아직 없는 줄 ${table.pending_count}`;
wrap.append(caption);
const scroller = document.createElement("div");
scroller.className = "b08-grid__scroll";
const element = document.createElement("table");
element.className = "b08-grid__table b08-grid__table--summary";
const head = document.createElement("thead");
const headRow = document.createElement("tr");
for (const label of table.columns) {
const th = document.createElement("th");
th.textContent = label;
headRow.append(th);
}
head.append(headRow);
const body = document.createElement("tbody");
let lastGroup = "";
for (const row of table.rows) {
const tr = document.createElement("tr");
tr.append(textCell(row.group === lastGroup ? "" : row.group, "b08-grid__station"));
lastGroup = row.group;
tr.append(textCell(row.item));
tr.append(textCell(row.unit, "b08-grid__unit"));
// 값이 없으면 빈칸이 아니라 「-」 — 빈칸이면 0 으로 오해된다.
tr.append(textCell(row.amount === null ? "" : num(row.amount, 2)));
tr.append(textCell(row.status));
const note = textCell(row.reason.replace(/\*\*/g, ""), "b08-grid__note");
if (row.reference_amount) {
note.append(document.createTextNode(` (참고 면적 ${num(row.reference_amount, 1)})`));
}
tr.append(note);
body.append(tr);
}
element.append(head, body);
scroller.append(element);
wrap.append(scroller);
return wrap;
}
+40 -4
View File
@@ -33,7 +33,7 @@ from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from typing import Any, Iterable
from common_util.common_util_json import atomic_write_json
@@ -57,6 +57,17 @@ ROCK_CLASS_SETS: dict[str, tuple[str, ...]] = {
}
DEFAULT_ROCK_CLASS_SET = "geochang5"
# 암 시공법 — 품셈이 공종을 가르는 기준. `None` 은 「아직 안 정함」이고 기본값이다.
ROCK_METHOD_RIPPING = "ripping" # 긁어내기 — 암절취
ROCK_METHOD_BLASTING = "blasting" # 터뜨리기 — 발파암
ROCK_METHODS = (ROCK_METHOD_RIPPING, ROCK_METHOD_BLASTING)
def rock_method(settings: dict[str, Any], rock_class: str) -> str | None:
"""갈래 하나의 시공법. 안 정했으면 `None` — **기본값으로 때우지 않는다.**"""
value = (settings.get("rock_methods") or {}).get(rock_class)
return value if value in ROCK_METHODS else None
def default_settings() -> dict[str, Any]:
"""빈 설정. `estimation` 은 **자리만** 만든다 — 채우는 것은 B09 몫이다."""
@@ -68,13 +79,24 @@ def default_settings() -> dict[str, Any]:
# 갈래별 비율(%). 설계자가 넣는 값이라 기본은 비워 둔다 —
# 측점별 암질 판정에 기대지 않는다는 것이 8-1 사용자 확정이다.
"rock_ratios_pct": {},
# 갈래별 **시공법** — `{갈래이름: "ripping"|"blasting"}`.
# ⚠ 갈래 이름(연암·보통암…)만으로는 **긁어내는 암인지 터뜨리는 암인지** 알 수 없고,
# 품셈은 그 둘을 다른 공종으로 둔다(암절취 FP-09-04 / 발파암 FP-09-05).
# 기본은 **비워 둔다** — 찍으면 공종이 조용히 틀린다. 안 정하면 인계에서
# 「시공법 미지정」으로 드러난다(2026-09-07 일감 9 에서 드러난 자리).
"rock_methods": {},
"conversion_factors_override": None,
"haul_limits_m_override": None,
"application_ratios_pct": {key: 100 for key in APPLICATION_RATIO_KEYS},
# 자재총괄의 관급/사급 구분 — `{자재명: "public"|"private"}`.
# 자재총괄의 관급/사급 구분 — `{자재명: "owner_supplied"|"contractor_supplied"}`
# 또는 `{자재명: {"supply": …, "install_by": "contractor"|"owner"}}`.
# ⚠ **법이 아니라 발주 결정**이라 기본은 비워 둔다. 안 정한 자재는 「미분류」로
# 화면에 드러난다 — 사급으로 조용히 넘기면 관급자재대가 새 나간다.
"material_supply": {},
# 콘크리트 타설 방식 — `ready_mixed`(FP-12-01-01) / `machine_mixed`(-02) /
# `hand_mixed`(-03). **설계 판단**이라 사용자가 고르는 값이고, 기본은 실무가
# 쓰는 레디믹스트로 둔다. ⚠ 잠정이며 사용자 확정 대기 항목이다.
"concrete_placing_method": "ready_mixed",
"dataset_versions": {},
},
"estimation": {
@@ -124,16 +146,30 @@ def _merge(base: dict[str, Any], stored: dict[str, Any]) -> dict[str, Any]:
SECTIONS = ("quantity", "estimation")
def save_section(project_root: str | Path, section: str, values: dict[str, Any]) -> dict[str, Any]:
def save_section(
project_root: str | Path,
section: str,
values: dict[str, Any],
*,
replace_keys: Iterable[str] = (),
) -> dict[str, Any]:
"""한 구획만 갈아 끼운다 — 남의 구획은 **손대지 않는다**.
페이지가 같은 파일을 쓰므로 통째로 덮으면 상대 값이 사라진다. 그래서 **통째로 쓰는
함수를 두지 않는다** 쓰려면 반드시 구획 이름을 대야 한다.
`replace_keys` **지울 있어야 하는 ** 병합이 아니라 통째로 갈아 끼운다.
고른 값을 정함으로 되돌리기 병합으로는 되기 때문이다(2026-09-07 화면에서
걸린 자리 시공법을 고르면 되돌릴 길이 없었다).
"""
if section not in SECTIONS:
raise ValueError(f"모르는 구획: {section} (쓸 수 있는 것: {', '.join(SECTIONS)})")
settings = load_settings(project_root)
settings[section] = _merge(settings.get(section) or {}, values)
merged = _merge(settings.get(section) or {}, values)
for key in replace_keys:
if key in values:
merged[key] = values[key]
settings[section] = merged
settings["schema_version"] = SCHEMA_VERSION
atomic_write_json(settings_path(project_root), settings)
return settings
@@ -0,0 +1,47 @@
"""산출 요약 — 값의 **크기가 말이 되나**를 한눈에 보이는 자리 (2026-09-07 조율 창 권고).
있나
서브 창이 씨앗뿜어붙이기를 **합계 68.8**으로 세워 두고도 몰랐던 일이 있었다.
값이 **있기는 하니** 어떤 시험도 잡는다. 자릿수가 어긋난 것은 사람이 훑어야 보이고,
훑으려면 **최솟값·중앙값·최댓값이 옆에 있어야** 한다.
이것은 검사가 아니라 **눈에 띄게 하는 장치**
기준을 정해 놓고 걸러 내지 않는다 임도 물량은 ··m·ton·개가 섞여 있어 얼마 이하면
이상하다 벌로 정한다. **단위별로 나눠** 내고 판단은 사람에게 맡긴다.
"""
from __future__ import annotations
from statistics import median
from typing import Any, Iterable
def spread(values: Iterable[float]) -> dict[str, float] | None:
"""최솟값·중앙값·최댓값. 값이 없으면 `None` — 0 으로 만들지 않는다."""
numbers = [float(v) for v in values if isinstance(v, (int, float))]
if not numbers:
return None
return {
"min": min(numbers),
"median": float(median(numbers)),
"max": max(numbers),
"count": len(numbers),
}
def spread_by_unit(
rows: Iterable[dict[str, Any]], *, value_key: str
) -> dict[str, dict[str, float]]:
"""단위별로 갈라 낸다. ㎥ 와 ton 을 한 통에 넣으면 최솟값이 뜻을 잃는다."""
buckets: dict[str, list[float]] = {}
for row in rows:
value = row.get(value_key)
if not isinstance(value, (int, float)):
continue
buckets.setdefault(str(row.get("unit") or "?"), []).append(float(value))
result: dict[str, dict[str, float]] = {}
for unit, numbers in buckets.items():
found = spread(numbers)
if found:
result[unit] = found
return result
@@ -0,0 +1,68 @@
{
"schema_version": "1.0",
"dataset_id": "formwork_reuse",
"effective_date": "2026-01-01",
"note": "거푸집 사용횟수 — **품셈 1-7-1 원문**이 구조물 종류별로 정해 둔 값이다. 관측값이 아니라 법이므로 실무값으로 갈음하지 않는다.",
"source": {
"doc": "산림사업 표준품셈 1-7-1 거푸집 사용",
"table_id": "F0040",
"quote": "2회 T형보, 난간, 특히 복잡한 구조의 교각, 교대, 수문관의 본체 등 복잡한 구조 / 3회 슬래브, 교대, 교각, 옹벽, 파라펫트, 날개벽 등 약간 복잡한 구조 / 4회 측구, 수로, 확대기초, 우물통 등 비교적 간단한 구조 / 6회 수문 또는 관의 기초, 호안 및 보호공의 기초 등 극히 간단한 구조"
},
"policy": {
"b08_delivers": "접촉 면적(㎡) + 사용횟수. **횟수별 재료 환산은 하지 않는다**.",
"b09_applies": "품셈 12-4 의 「사용횟수별 기준수량에 대한 비율(%)」은 일위대가 재료비에 걸린다. B08 이 여기서 곱하면 B09 와 겹쳐 두 번 준다.",
"unlisted_is_flagged": true
},
"reuse_by_class": [
{
"reuse_count": 2,
"class": "복잡한 구조",
"examples": ["T형보", "난간", "복잡한 교각", "교대", "수문관 본체"]
},
{
"reuse_count": 3,
"class": "약간 복잡한 구조",
"examples": ["슬래브", "교대", "교각", "옹벽", "파라펫트", "날개벽"]
},
{
"reuse_count": 4,
"class": "비교적 간단한 구조",
"examples": ["측구", "수로", "확대기초", "우물통"]
},
{
"reuse_count": 6,
"class": "극히 간단한 구조",
"examples": ["수문 기초", "관의 기초", "호안 기초", "보호공 기초"]
}
],
"type_map": [
{
"type_id": "retaining_wall",
"reuse_count": 3,
"matched_example": "옹벽",
"note": "원문 3회 줄에 「옹벽」이 그대로 있음"
},
{
"type_id": "pipe_inlet_basin",
"reuse_count": 6,
"matched_example": "보호공 기초",
"note": "관보호공 집수정 — 원문 6회 줄의 「호안 및 보호공의 기초」에 해당. ⚠ 벽체까지 6회로 볼지는 확인 필요"
},
{
"type_id": "ford_pavement",
"reuse_count": null,
"note": "물넘이포장은 거푸집이 서지 않는 구조(면 포장) — 대상 아님"
}
],
"reuse_ratio_pct": {
"note": "품셈 12-4 「사용횟수별 기준수량에 대한 비율(%)」. **B09 일위대가가 쓰는 값**이며 B08 은 참고로만 싣는다 — 여기서 곱하면 이중계상.",
"table_id": "F0336",
"plywood": { "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": {
"note": "강관동바리(품셈 12-20)는 **슬래브를 떠받칠 때** 필요하다. 지금 서는 구조물(옹벽·집수정)은 벽체 거푸집만이라 대상이 아니다.",
"targets_pending": ["box_culvert", "ford_bridge"],
"why": "그 둘은 원단위·치수가 미확보라 슬래브 면적 자체가 안 나온다 — 동바리도 함께 미확보"
}
}
@@ -0,0 +1,121 @@
{
"schema_version": "1.0",
"dataset_id": "structure_unit_observed",
"effective_date": "2026-01-01",
"note": "콘크리트 구조물의 **관측** 원단위표. 품셈에는 구조물별 표준 물량표가 없어(구조물_수량.md · 배수공_수량.md §2) 실무 설계원본에서 뽑은 값이다.",
"policy": {
"basis": "observed",
"no_interpolation": true,
"no_invented_dimensions": true,
"notes": [
"⚠ 관측값은 **그 규격에서만** 맞다. 규격이 다르면 비례로 늘리지 않는다 — 벽 두께·기초는 높이에 비례하지 않는다.",
"⚠ 규격이 표에 없으면 「원단위 미확보」로 드러낸다. 가까운 값을 갖다 쓰지 않는다.",
"⚠ 줄마다 basis 를 싣는다 — 치수에서 나온 값(derived)과 한 표에 섞이기 때문이다."
]
},
"sources": {
"uljin_library": {
"doc": "울진소광 구조도 숨김탭 원단위 라이브러리",
"path": "resources/knowledge/original/실무문서/_원단위라이브러리_울진소광.md"
},
"uljin_compare": {
"doc": "종합비교 04 — 임도 구조물 원단위 (울진 1공구 수량집계표 관측)",
"path": "resources/knowledge/original/실무문서/_종합비교/04_임도구조물_원단위.md"
}
},
"entries": [
{
"type_id": "retaining_wall",
"spec": { "form": "반중력식", "height_m": 2.0 },
"unit": "m",
"source": "uljin_library",
"source_note": "§7 옹벽류 — 반중력식옹벽 H=2.0",
"components": [
{ "name": "콘크리트", "unit": "㎥", "amount": 1.35, "destination": "unit_price", "basis_note": "기초 0.75 + 벽체 0.60" },
{ "name": "버림콘크리트", "unit": "㎥", "amount": 0.15, "destination": "unit_price" },
{ "name": "유로폼", "unit": "㎡", "amount": 3.2, "destination": "unit_price", "basis_note": "배면+전면" },
{ "name": "합판거푸집", "unit": "㎡", "amount": 0.6, "destination": "unit_price", "basis_note": "기초" },
{ "name": "물구멍", "unit": "m", "amount": 0.32, "destination": "material", "basis_note": "Ø50" },
{ "name": "이형철근 D13", "unit": "kg", "amount": 13.45, "destination": "material" },
{ "name": "이형철근 D16", "unit": "kg", "amount": 30.42, "destination": "material" }
]
},
{
"type_id": "pipe_inlet_basin",
"spec": { "inlet_basin_form": "돌집수정 ㄷ형" },
"unit": "개소",
"source": "uljin_compare",
"source_note": "관보호공 돌집수정 ㄷ형 /개소",
"components": [
{ "name": "콘크리트", "unit": "㎥", "amount": 4.03, "destination": "unit_price" },
{ "name": "모르터", "unit": "㎥", "amount": 0.157, "destination": "unit_price" },
{ "name": "터파기", "unit": "㎥", "amount": 21.1, "destination": "earthwork", "basis_note": "토사 14.8 + 암 6.3 — 지반 구분은 토공집계가 다시 가름" },
{ "name": "되메우기", "unit": "㎥", "amount": 2.6, "destination": "earthwork" },
{ "name": "잔토처리", "unit": "㎥", "amount": 18.5, "destination": "earthwork" }
]
},
{
"type_id": "pipe_inlet_basin",
"spec": { "inlet_basin_form": "돌집수정 ㄴ형" },
"unit": "개소",
"source": "uljin_compare",
"source_note": "관보호공 돌집수정 ㄴ형 /개소",
"components": [
{ "name": "콘크리트", "unit": "㎥", "amount": 2.69, "destination": "unit_price" },
{ "name": "모르터", "unit": "㎥", "amount": 0.096, "destination": "unit_price" },
{ "name": "터파기", "unit": "㎥", "amount": 16.4, "destination": "earthwork", "basis_note": "토사 4.9 + 암 11.5" },
{ "name": "되메우기", "unit": "㎥", "amount": 1.2, "destination": "earthwork" },
{ "name": "잔토처리", "unit": "㎥", "amount": 15.2, "destination": "earthwork" }
]
},
{
"type_id": "pipe_inlet_basin",
"spec": { "inlet_basin_form": "□형(기본형)", "inlet_basin_material": "콘크리트", "pipe_diameter_mm": "800" },
"unit": "개소",
"source": "uljin_library",
"source_note": "§2 집수정 Ø800 — 내부 3.0×1.0×1.2, 벽 0.2, 바닥기초 3.4×1.4×0.2",
"components": [
{ "name": "콘크리트", "unit": "㎥", "amount": 2.84, "destination": "unit_price" },
{ "name": "합판거푸집", "unit": "㎡", "amount": 21.28, "destination": "unit_price" },
{ "name": "이형철근 D13", "unit": "kg", "amount": 4.78, "destination": "material" },
{ "name": "면목", "unit": "m", "amount": 12.67, "destination": "material", "basis_note": "A25" },
{ "name": "터파기", "unit": "㎥", "amount": 10.64, "destination": "earthwork" },
{ "name": "되메우기", "unit": "㎥", "amount": 6.44, "destination": "earthwork" },
{ "name": "잔토처리", "unit": "㎥", "amount": 4.2, "destination": "earthwork" }
]
},
{
"type_id": "ford_pavement",
"spec": { "thickness_cm": 20 },
"unit": "㎡",
"source": "uljin_compare",
"source_note": "콘크리트포장 T=20cm /㎡",
"components": [
{ "name": "레미콘", "unit": "㎥", "amount": 0.2, "destination": "unit_price" },
{ "name": "와이어메쉬", "unit": "㎡", "amount": 1.16, "destination": "material" },
{ "name": "터파기", "unit": "㎥", "amount": 0.2, "destination": "earthwork" },
{ "name": "잔토처리", "unit": "㎥", "amount": 0.2, "destination": "earthwork" }
]
}
],
"not_found": {
"note": "규격은 우리 모델에 있으나 **관측 원단위가 어디에도 없는** 것. 지어내지 않는다.",
"items": [
{
"type_id": "box_culvert",
"why": "울진 2공구에 BOX암거가 실재하나 원단위 라이브러리에 탭이 없음. 게다가 structures.json 의 BOX 제원은 body_width_m·body_height_m 와 날개벽뿐이라 **벽·저판·상판 두께가 없어 전개식도 못 세움**.",
"needs": "표준 단면(벽·저판·상판 두께) 확보 — 사용자 확정 대기"
},
{
"type_id": "ford_bridge",
"why": "세월교 본체(날개벽 포함) 원단위 없음. 관 부분은 pipe 로 따로 섬.",
"needs": "표준도 물량 또는 실무 관측"
},
{
"type_id": "retaining_wall",
"spec": { "form": "반중력식", "height_m": 1.6 },
"why": "울진 2공구에 H=1.6 이 실재하나 수치가 라이브러리에 없음. H=2.0 값을 비례로 줄이지 않음 — 기초·벽체는 높이에 비례하지 않음."
}
]
}
}
@@ -125,6 +125,16 @@
"work_item_code": "FP-13-04-02",
"master_name": "돌쌓기 > 메쌓기(장비)",
"note": "인력 시공이면 FP-13-04-01"
},
{
"type_id": "pipe_inlet_basin",
"work_item_code": "FP-12-15",
"master_name": "집수정"
},
{
"type_id": "ford_pavement",
"work_item_code": "FP-12-06",
"master_name": "콘크리트 포장(인력시공)"
}
],
"pending_user": {
@@ -132,14 +142,59 @@
"items": [
{
"group": "지장목제거",
"candidates": ["FP-04-01 수확베기", "FP-04-02 단목베기", "FP-04-03 위험목 베기"],
"candidates": [
"FP-04-01 수확베기",
"FP-04-02 단목베기",
"FP-04-03 위험목 베기"
],
"why": "품셈 4장은 벌목을 목적별로 가르는데 임도 지장목이 어느 쪽인지 원본이 말하지 않음"
},
{
"group": "흙깎기/측구터파기 암",
"candidates": ["FP-09-04 암절취(리핑)", "FP-09-05 발파암"],
"candidates": [
"FP-09-04 암절취(리핑)",
"FP-09-05 발파암"
],
"why": "설계자가 넣는 암 갈래 이름(풍화암·연암·보통암·경암)이 리핑이냐 발파냐를 말하지 않음. 갈래마다 시공법을 지정하는 칸이 필요함"
}
]
},
"composite": {
"note": "품셈에 **그 이름의 공종이 없어** 여러 공종을 묶어 일위대가로 세우는 자리. 코드 하나로 못 적으므로 묶음을 적어 둔다 — 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다.",
"items": [
{
"type_id": "retaining_wall",
"parts": [
"FP-12-01-01#철근구조물",
"FP-12-04 합판거푸집",
"FP-12-03 철근 현장가공 및 조림",
"FP-12-25 기초잡석"
],
"why": "품셈 12장에 「옹벽」 공종이 없음. 실무 내역은 「반중력식옹벽 H=2.0」 한 줄이고 그 일위대가가 위 공종을 묶음.",
"needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김",
"placing_note": "타설 코드는 프로젝트 설정의 타설 방식으로 갈림(기본 레디믹스트). 철근구조물 판정은 원단위의 D13·D16 에서 자동으로 나옴.",
"not_ready": [
"FP-12-03",
"FP-12-25"
],
"not_ready_why": "B09 일위대가가 아직 안 섬 — 지금 세우면 절반짜리가 됨(2026-09-07 조율 창)"
}
]
},
"concrete_placing": {
"note": "콘크리트 타설은 **타설 방식 × 구조물 종류**로 갈린다. 방식은 설계 판단이라 프로젝트 설정(`quantity.concrete_placing_method`)이 고르고, 종류는 **원단위에 철근이 있나 없나로 자동 판정**한다 — 사람이 고르는 값이 아니다(2026-09-07 3자 확정).",
"method_codes": {
"ready_mixed": "FP-12-01-01",
"machine_mixed": "FP-12-01-02",
"hand_mixed": "FP-12-01-03"
},
"default_method": "ready_mixed",
"default_is_provisional": true,
"structure_kinds": [
"무근구조물",
"철근구조물",
"소형구조물"
],
"kind_rule": "원단위 성분에 철근(이형철근·원형철근)이 있으면 철근구조물, 없으면 무근구조물. 소형구조물 판정 기준은 미확보."
}
}
@@ -1,7 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-08T00:00:17+09:00",
"generated_at": "2026-09-08T01:00:54+09:00",
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
"source": {
"dataset_id": "pum_forest",
@@ -12,13 +12,18 @@
"files": [
{
"file": "work_item_master_2026-01-01.json",
"sha256": "593653135d5a2871180e7a3921f9238629b275438ef47230412aa21e9bdd80c0",
"size_bytes": 725931
"sha256": "fe454c56c9dc01ad7dae04a8f90d776d08c5ce33badeadb2606b35234bfce7eb",
"size_bytes": 785034
},
{
"file": "form_undetermined_2026-01-01.json",
"sha256": "7334dab9385bc1615a9cdb557ba814482e9a63d83b9e1232946918bfd8b5577f",
"size_bytes": 37139
},
{
"file": "basis_missing_2026-01-01.json",
"sha256": "27f8d80d10b791df8502e633c1d279ce2acce63f871eae6b0c4eb26ca8290e2e",
"size_bytes": 20674
}
]
}
@@ -0,0 +1,872 @@
{
"schema_version": "1.0",
"dataset_id": "work_item_master_basis_missing",
"effective_date": "2026-01-01",
"note": "밑수(「10㎡당」 같은 기준 수량)를 못 찾은 표. **1 단위당으로 단정하지 말 것** — 곱셈이 10배·100배 틀린다. 값을 곱해야 하는 형태(requirement·productivity)만 담는다.",
"items": [
{
"pum_table_id": "F0042",
"section": "2-1-1. 휘발유․오일",
"pum_form": "requirement",
"line": 1305
},
{
"pum_table_id": "F0043",
"section": "2-1-1. 휘발유․오일",
"pum_form": "requirement",
"line": 1328
},
{
"pum_table_id": "F0049",
"section": "2-1-4. 페인트 및 마킹테이프",
"pum_form": "requirement",
"line": 1402
},
{
"pum_table_id": "F0050",
"section": "2-1-4. 페인트 및 마킹테이프",
"pum_form": "requirement",
"line": 1417
},
{
"pum_table_id": "F0052",
"section": "2-1-4. 페인트 및 마킹테이프",
"pum_form": "requirement",
"line": 1444
},
{
"pum_table_id": "F0061",
"section": "2-1-11. 지상 약제살포",
"pum_form": "requirement",
"line": 1570
},
{
"pum_table_id": "F0075",
"section": "3-1. 경계표시",
"pum_form": "requirement",
"line": 1731
},
{
"pum_table_id": "F0077",
"section": "3-3. 작업로 설치",
"pum_form": "requirement",
"line": 1754
},
{
"pum_table_id": "F0078",
"section": "3-4-3. 임산물 운반로 및 작업로 보수비 산정",
"pum_form": "requirement",
"line": 1815
},
{
"pum_table_id": "F0080",
"section": "3-6. 산물 임내정리",
"pum_form": "requirement",
"line": 1867
},
{
"pum_table_id": "F0081",
"section": "3-7. 재해산물 수집",
"pum_form": "requirement",
"line": 1887
},
{
"pum_table_id": "F0082",
"section": "3-8. 드론 영상 촬영",
"pum_form": "requirement",
"line": 1901
},
{
"pum_table_id": "F0083",
"section": "4-1-1. 임업용 동력기계톱",
"pum_form": "productivity",
"line": 1929
},
{
"pum_table_id": "F0084",
"section": "4-1-2. 하베스터(부착형 스트로크)",
"pum_form": "productivity",
"line": 1947
},
{
"pum_table_id": "F0085",
"section": "4-1-2. 하베스터(부착형 스트로크)",
"pum_form": "requirement",
"line": 1957
},
{
"pum_table_id": "F0086",
"section": "4-2-1. 100본당",
"pum_form": "requirement",
"line": 1967
},
{
"pum_table_id": "F0087",
"section": "4-2-2. 1,000㎡당",
"pum_form": "requirement",
"line": 1997
},
{
"pum_table_id": "F0088",
"section": "4-3. 위험목 베기",
"pum_form": "requirement",
"line": 2012
},
{
"pum_table_id": "F0089",
"section": "4-4. 가지정리",
"pum_form": "requirement",
"line": 2041
},
{
"pum_table_id": "F0090",
"section": "4-5. 벌도 위험목 점검",
"pum_form": "requirement",
"line": 2052
},
{
"pum_table_id": "F0091",
"section": "4-6. 벌목부 작업안전 보조",
"pum_form": "requirement",
"line": 2062
},
{
"pum_table_id": "F0092",
"section": "5-1-1. 관목굴취",
"pum_form": "requirement",
"line": 2081
},
{
"pum_table_id": "F0093",
"section": "5-1-2. 교목굴취(나무높이)",
"pum_form": "requirement",
"line": 2099
},
{
"pum_table_id": "F0094",
"section": "5-1-3. 교목굴취(근원직경)",
"pum_form": "requirement",
"line": 2125
},
{
"pum_table_id": "F0095",
"section": "5-1-3. 교목굴취(근원직경)",
"pum_form": "requirement",
"line": 2156
},
{
"pum_table_id": "F0097",
"section": "5-1-5. 떼운반 적재 기준표",
"pum_form": "requirement",
"line": 2177
},
{
"pum_table_id": "F0098",
"section": "5-2. 뿌리돌림",
"pum_form": "requirement",
"line": 2194
},
{
"pum_table_id": "F0099",
"section": "5-3-1. 나무식재",
"pum_form": "requirement",
"line": 2219
},
{
"pum_table_id": "F0102",
"section": "5-3-2. 관목식재(단식)",
"pum_form": "requirement",
"line": 2254
},
{
"pum_table_id": "F0103",
"section": "5-3-3. 관목식재(군식)",
"pum_form": "requirement",
"line": 2272
},
{
"pum_table_id": "F0104",
"section": "5-3-4. 교목식재(나무높이)",
"pum_form": "requirement",
"line": 2291
},
{
"pum_table_id": "F0106",
"section": "5-3-5. 교목식재(흉고직경)",
"pum_form": "requirement",
"line": 2322
},
{
"pum_table_id": "F0108",
"section": "5-3-5. 교목식재(흉고직경)",
"pum_form": "requirement",
"line": 2355
},
{
"pum_table_id": "F0109",
"section": "5-4. 파종조림",
"pum_form": "requirement",
"line": 2366
},
{
"pum_table_id": "F0110",
"section": "5-5. 천연하종갱신",
"pum_form": "requirement",
"line": 2383
},
{
"pum_table_id": "F0111",
"section": "5-6. 움싹갱신",
"pum_form": "requirement",
"line": 2396
},
{
"pum_table_id": "F0112",
"section": "5-7. 생태보완조림",
"pum_form": "requirement",
"line": 2409
},
{
"pum_table_id": "F0113",
"section": "5-8. 큰나무 공익조림",
"pum_form": "requirement",
"line": 2426
},
{
"pum_table_id": "F0114",
"section": "5-9. 해안조림",
"pum_form": "requirement",
"line": 2439
},
{
"pum_table_id": "F0116",
"section": "5-11. 사초심기",
"pum_form": "requirement",
"line": 2475
},
{
"pum_table_id": "F0117",
"section": "5-12. 떼붙임(재배잔디)",
"pum_form": "requirement",
"line": 2496
},
{
"pum_table_id": "F0118",
"section": "5-13. 떼심기",
"pum_form": "requirement",
"line": 2511
},
{
"pum_table_id": "F0121",
"section": "5-16-1. 단끊기",
"pum_form": "requirement",
"line": 2565
},
{
"pum_table_id": "F0126",
"section": "5-19-1. 표토절취 및 모으기",
"pum_form": "requirement",
"line": 2653
},
{
"pum_table_id": "F0129",
"section": "5-21. 표토이식",
"pum_form": "requirement",
"line": 2689
},
{
"pum_table_id": "F0132",
"section": "5-22-4. 평떼 시비",
"pum_form": "requirement",
"line": 2731
},
{
"pum_table_id": "F0143",
"section": "5-27. 식재면 관리",
"pum_form": "requirement",
"line": 2905
},
{
"pum_table_id": "F0144",
"section": "5-28-1. 짚망",
"pum_form": "requirement",
"line": 2919
},
{
"pum_table_id": "F0153",
"section": "6-1. 비료주기",
"pum_form": "requirement",
"line": 3062
},
{
"pum_table_id": "F0154",
"section": "6-2-1. 둘레베기",
"pum_form": "requirement",
"line": 3079
},
{
"pum_table_id": "F0155",
"section": "6-2-2. 줄베기",
"pum_form": "requirement",
"line": 3089
},
{
"pum_table_id": "F0156",
"section": "6-2-3. 모두베기",
"pum_form": "requirement",
"line": 3105
},
{
"pum_table_id": "F0157",
"section": "6-3. 맹아제거",
"pum_form": "requirement",
"line": 3122
},
{
"pum_table_id": "F0159",
"section": "6-4-2. 덩굴 약제 살포처리",
"pum_form": "requirement",
"line": 3153
},
{
"pum_table_id": "F0160",
"section": "6-4-3. 소금처리",
"pum_form": "requirement",
"line": 3164
},
{
"pum_table_id": "F0161",
"section": "6-4-4. 뿌리제거",
"pum_form": "requirement",
"line": 3183
},
{
"pum_table_id": "F0163",
"section": "6-5. 어린나무 가꾸기",
"pum_form": "requirement",
"line": 3244
},
{
"pum_table_id": "F0164",
"section": "6-6. 가지치기 및 수형교정",
"pum_form": "requirement",
"line": 3276
},
{
"pum_table_id": "F0165",
"section": "6-7-1. 교목 시비",
"pum_form": "requirement",
"line": 3304
},
{
"pum_table_id": "F0166",
"section": "6-7-2. 관목 시비",
"pum_form": "requirement",
"line": 3318
},
{
"pum_table_id": "F0170",
"section": "7-1-1. 수확",
"pum_form": "requirement",
"line": 3371
},
{
"pum_table_id": "F0171",
"section": "7-1-1. 수확",
"pum_form": "requirement",
"line": 3381
},
{
"pum_table_id": "F0172",
"section": "7-1-2. 숲가꾸기, 병해충방제",
"pum_form": "requirement",
"line": 3395
},
{
"pum_table_id": "F0174",
"section": "7-3. 아키야윈치(임업용 윈치) 집재",
"pum_form": "requirement",
"line": 3439
},
{
"pum_table_id": "F0175",
"section": "7-4-1. 수확",
"pum_form": "requirement",
"line": 3452
},
{
"pum_table_id": "F0176",
"section": "7-4-2. 숲가꾸기, 산림병해충방제",
"pum_form": "requirement",
"line": 3476
},
{
"pum_table_id": "F0177",
"section": "7-5-1. 수확",
"pum_form": "requirement",
"line": 3496
},
{
"pum_table_id": "F0178",
"section": "7-5-2. 숲가꾸기, 병해충방제",
"pum_form": "requirement",
"line": 3519
},
{
"pum_table_id": "F0179",
"section": "7-6. 스윙야더 집재",
"pum_form": "requirement",
"line": 3542
},
{
"pum_table_id": "F0180",
"section": "7-7-1. 수확",
"pum_form": "requirement",
"line": 3556
},
{
"pum_table_id": "F0181",
"section": "7-7-2. 숲가꾸기, 병해충방제",
"pum_form": "requirement",
"line": 3580
},
{
"pum_table_id": "F0182",
"section": "7-8-1. 가선설치",
"pum_form": "requirement",
"line": 3607
},
{
"pum_table_id": "F0183",
"section": "7-8-2. 가선해체",
"pum_form": "requirement",
"line": 3619
},
{
"pum_table_id": "F0184",
"section": "7-8-3. 집재 소요인력",
"pum_form": "requirement",
"line": 3631
},
{
"pum_table_id": "F0185",
"section": "7-9-1. 수확",
"pum_form": "requirement",
"line": 3656
},
{
"pum_table_id": "F0186",
"section": "7-9-2. 숲가꾸기, 소나무재선충병방제",
"pum_form": "requirement",
"line": 3673
},
{
"pum_table_id": "F0191",
"section": "7-11. 동력상하차기(우드그래플) 집재-수확",
"pum_form": "requirement",
"line": 3745
},
{
"pum_table_id": "F0192",
"section": "7-12. 동력상하차기(우드그래플) 집적",
"pum_form": "requirement",
"line": 3763
},
{
"pum_table_id": "F0201",
"section": "8-1-1. 약제주입기",
"pum_form": "requirement",
"line": 3945
},
{
"pum_table_id": "F0202",
"section": "8-1-2. 약제주입병",
"pum_form": "requirement",
"line": 3964
},
{
"pum_table_id": "F0203",
"section": "8-2-1. 소나무재선충병",
"pum_form": "requirement",
"line": 3982
},
{
"pum_table_id": "F0204",
"section": "8-2-1. 소나무재선충병",
"pum_form": "requirement",
"line": 3999
},
{
"pum_table_id": "F0206",
"section": "8-2-1. 소나무재선충병",
"pum_form": "requirement",
"line": 4023
},
{
"pum_table_id": "F0207",
"section": "8-2-1. 소나무재선충병",
"pum_form": "requirement",
"line": 4041
},
{
"pum_table_id": "F0209",
"section": "8-2-2. 솔잎혹파리",
"pum_form": "requirement",
"line": 4080
},
{
"pum_table_id": "F0210",
"section": "8-2-2. 솔잎혹파리",
"pum_form": "requirement",
"line": 4108
},
{
"pum_table_id": "F0211",
"section": "8-2-2. 솔잎혹파리",
"pum_form": "requirement",
"line": 4136
},
{
"pum_table_id": "F0213",
"section": "8-2-3. 솔껍질깍지벌레",
"pum_form": "requirement",
"line": 4182
},
{
"pum_table_id": "F0214",
"section": "8-2-3. 솔껍질깍지벌레",
"pum_form": "requirement",
"line": 4210
},
{
"pum_table_id": "F0215",
"section": "8-2-3. 솔껍질깍지벌레",
"pum_form": "requirement",
"line": 4238
},
{
"pum_table_id": "F0217",
"section": "8-2-4. 솔나방",
"pum_form": "requirement",
"line": 4279
},
{
"pum_table_id": "F0219",
"section": "8-2-5. 푸사리움가지마름병",
"pum_form": "requirement",
"line": 4320
},
{
"pum_table_id": "F0224",
"section": "8-5. 페르몬 유인트랩",
"pum_form": "requirement",
"line": 4434
},
{
"pum_table_id": "F0232",
"section": "8-7. 방제 실행 등록",
"pum_form": "requirement",
"line": 4583
},
{
"pum_table_id": "F0235",
"section": "8-9. 잔가지줍기",
"pum_form": "requirement",
"line": 4623
},
{
"pum_table_id": "F0236",
"section": "8-10. 그물망 피복",
"pum_form": "requirement",
"line": 4631
},
{
"pum_table_id": "F0237",
"section": "8-11. 이동식 임목 파쇄",
"pum_form": "productivity",
"line": 4649
},
{
"pum_table_id": "F0238",
"section": "9-2. 노선 굴진 보조원",
"pum_form": "requirement",
"line": 4675
},
{
"pum_table_id": "F0241",
"section": "9-4-1. 암파쇄",
"pum_form": "productivity",
"line": 4719
},
{
"pum_table_id": "F0244",
"section": "9-5-2. 깎기(90%)",
"pum_form": "productivity",
"line": 4759
},
{
"pum_table_id": "F0251",
"section": "9-8-1. T=30㎝ 미만",
"pum_form": "requirement",
"line": 4860
},
{
"pum_table_id": "F0294",
"section": "9-21. 제근",
"pum_form": "requirement",
"line": 5512
},
{
"pum_table_id": "F0310",
"section": "10-7-4. 모노레일 운반",
"pum_form": "requirement",
"line": 5770
},
{
"pum_table_id": "F0313",
"section": "10-8-1. 짐내리기",
"pum_form": "requirement",
"line": 5813
},
{
"pum_table_id": "F0314",
"section": "10-8-2. 운반대 설치",
"pum_form": "requirement",
"line": 5829
},
{
"pum_table_id": "F0317",
"section": "10-10-1. 콘크리트 및 골재운반(지상)",
"pum_form": "requirement",
"line": 5884
},
{
"pum_table_id": "F0318",
"section": "10-10-2. 그 외 자재의 운반품셈",
"pum_form": "requirement",
"line": 5894
},
{
"pum_table_id": "F0337",
"section": "12-5. 문양거푸집(07m)",
"pum_form": "requirement",
"line": 6217
},
{
"pum_table_id": "F0339",
"section": "12-7-1. 포장절단",
"pum_form": "requirement",
"line": 6254
},
{
"pum_table_id": "F0340",
"section": "12-7-2. 줄눈설치",
"pum_form": "requirement",
"line": 6269
},
{
"pum_table_id": "F0341",
"section": "12-8. 콘크리트 포장 거푸집",
"pum_form": "requirement",
"line": 6280
},
{
"pum_table_id": "F0350",
"section": "12-12. 날개벽",
"pum_form": "requirement",
"line": 6421
},
{
"pum_table_id": "F0379",
"section": "12-29. 스페이셔 설치(몰탈 블록)",
"pum_form": "requirement",
"line": 6775
},
{
"pum_table_id": "F0405",
"section": "13-3. 기초다짐 및 뒤채움’ 항을 적용한다.",
"pum_form": "requirement",
"line": 7087
},
{
"pum_table_id": "F0412",
"section": "13-4-4. 찰쌓기(인력)",
"pum_form": "requirement",
"line": 7174
},
{
"pum_table_id": "F0413",
"section": "13-4-4. 찰쌓기(인력)",
"pum_form": "requirement",
"line": 7185
},
{
"pum_table_id": "F0430",
"section": "13-10-2. 나무 말뚝박기",
"pum_form": "requirement",
"line": 7491
},
{
"pum_table_id": "F0437",
"section": "13-12-1. 뭉기기",
"pum_form": "requirement",
"line": 7612
},
{
"pum_table_id": "F0438",
"section": "13-12-2. 지오셀(사면보강)",
"pum_form": "requirement",
"line": 7625
},
{
"pum_table_id": "F0444",
"section": "13-14. 식생토낭 및 포트",
"pum_form": "requirement",
"line": 7715
},
{
"pum_table_id": "F0447",
"section": "13-15-2. 목책 설치",
"pum_form": "requirement",
"line": 7745
},
{
"pum_table_id": "F0454",
"section": "8-2-3 굴착기(2025)」를 참조하여 적용계수를 달리 적용하도록 한다.",
"pum_form": "requirement",
"line": 7983
},
{
"pum_table_id": "F0455",
"section": "2-1. 풀베기, (1) 둘레베기(조림목 본수 2,700본/ha, 조림1년차)",
"pum_form": "requirement",
"line": 8031
},
{
"pum_table_id": "F0456",
"section": "2-2. 풀베기, (2) 모두베기(조림목 본수 2,700본/ha, 조림2년차)",
"pum_form": "requirement",
"line": 8061
},
{
"pum_table_id": "F0457",
"section": "2-3. 풀베기, (3) 줄베기(조림목 본수 2,700본/ha, 조림2년차)",
"pum_form": "requirement",
"line": 8092
},
{
"pum_table_id": "F0458",
"section": "2-4. 풀베기, (4) 맹아제거+둘레베기(제거대상 맹아 1,000본/ha. 조림목 본수 2,700본/ha, 조림1년차)",
"pum_form": "requirement",
"line": 8124
},
{
"pum_table_id": "F0459",
"section": "2-5. 덩굴제거, (1) 지상부 덩굴걷기(큰나무 피해지. 덩굴 피복도 20~40%미만)",
"pum_form": "requirement",
"line": 8156
},
{
"pum_table_id": "F0460",
"section": "2-6. 덩굴제거, (2) 지상부 약제살포(큰나무 피해지. 덩굴 피복도 60~80%미만)",
"pum_form": "requirement",
"line": 8189
},
{
"pum_table_id": "F0461",
"section": "2-7. 덩굴제거, (3) 뿌리굴취(풀베기단계 조림지 1㎝미만 500본, 1~4㎝ 400본, 4㎝초과 100본)",
"pum_form": "requirement",
"line": 8225
},
{
"pum_table_id": "F0462",
"section": "2-8. 덩굴제거, (4) 뿌리굴취(풀베기단계 조림지 1㎝미만 500본, 14㎝ 400본,",
"pum_form": "requirement",
"line": 8265
},
{
"pum_table_id": "F0463",
"section": "2-9. 어린나무가꾸기, (1) 치수림단계 (20m 간격 소작업로 설치, 제거대상 피복도 ‘소’, 가지치기 미실행)",
"pum_form": "requirement",
"line": 8303
},
{
"pum_table_id": "F0464",
"section": "2-10. 어린나무가꾸기, (2) 유령림단계 (제거대상 피복도 ‘밀’, 가지치기 잣나무 02m. 500본/ha)",
"pum_form": "requirement",
"line": 8344
},
{
"pum_table_id": "F0465",
"section": "2-11. 솎아베기, (1) 산물을 임내에 버리는 경우",
"pum_form": "requirement",
"line": 8385
},
{
"pum_table_id": "F0466",
"section": "2-12. 솎아베기, (2) 산물을 전간재로 생산하는 경우",
"pum_form": "requirement",
"line": 8434
},
{
"pum_table_id": "F0467",
"section": "2-13. 위험목 베기",
"pum_form": "requirement",
"line": 8496
},
{
"pum_table_id": "F0468",
"section": "2-14. 산물수집, (1) 인력집재(단목 집재 + 집적)",
"pum_form": "requirement",
"line": 8542
},
{
"pum_table_id": "F0469",
"section": "2-15. 산물수집, (2) 지면끌기집재(공정별 독립작업)",
"pum_form": "requirement",
"line": 8585
},
{
"pum_table_id": "F0470",
"section": "2-16. 산물수집, (3) 지면끌기집재 (동시작업)",
"pum_form": "requirement",
"line": 8655
},
{
"pum_table_id": "F0471",
"section": "2-17. 산물임내정리",
"pum_form": "requirement",
"line": 8720
},
{
"pum_table_id": "F0472",
"section": "3-1. 임업용 동력기계톱, 우드그래플, 소형트럭",
"pum_form": "requirement",
"line": 8753
},
{
"pum_table_id": "F0473",
"section": "3-2. 임업용 동력기계톱, 스마트집재기, 우드그래플, 초소형포워더",
"pum_form": "requirement",
"line": 8828
},
{
"pum_table_id": "F0474",
"section": "3-3. 하베스터, 타워야더, 우드그래플, 소형포워더",
"pum_form": "requirement",
"line": 8912
},
{
"pum_table_id": "F0475",
"section": "4-1. 소나무재선충병방제",
"pum_form": "requirement",
"line": 8989
},
{
"pum_table_id": "F0476",
"section": "4-2. 참나무시들음병방제",
"pum_form": "requirement",
"line": 9106
}
]
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -728,6 +728,11 @@ export const ui_locales_b2 = {
B08_Quantity_Tab_Haul: ["운반거리", "Haul Distance"],
B08_Quantity_Tab_UnitQuantity: ["구조물 원단위", "Structure Unit Quantity"],
B08_Quantity_Tab_Material: ["자재총괄", "Material Summary"],
B08_Quantity_Tab_Preparation: ["준비공·사방공", "Preparation & Erosion Control"],
B08_Quantity_Side_Method_Label: ["시공법", "Method"],
B08_Quantity_Method_Unset: ["안 정함", "Not set"],
B08_Quantity_Method_Ripping: ["긁어내기(암절취)", "Ripping"],
B08_Quantity_Method_Blasting: ["터뜨리기(발파암)", "Blasting"],
B08_Quantity_Material_Failed: [
"자재총괄을 불러오지 못했습니다.",
"Failed to load the material summary.",