Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1
This commit is contained in:
@@ -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 []),
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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 를 모를 때 쓴다."""
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. 문양거푸집(0~7m)",
|
||||
"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본, 1~4㎝ 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) 유령림단계 (제거대상 피복도 ‘밀’, 가지치기 잣나무 0~2m. 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
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user