Merge remote-tracking branch 'origin/dev' into 안티그래비티
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
@@ -33,114 +34,41 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Forms import judged_form
|
||||
# 표 한 장 읽기(형태·밑수·깃발)는 `_Table` — 옛 이름은 여기서도 그대로 부름(시험·다른 파일이 씀).
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Table import ( # noqa: F401
|
||||
REFERENCE_MARKS,
|
||||
basis_from_name,
|
||||
basis_from_source,
|
||||
basis_quantity_is_grouped,
|
||||
capacity_formula_pending,
|
||||
crew_table,
|
||||
detect_basis,
|
||||
detect_form,
|
||||
expression_cells,
|
||||
formula_rows,
|
||||
new_report,
|
||||
norm,
|
||||
person_basis_ok,
|
||||
resource_shares,
|
||||
spaced_names,
|
||||
special_glyphs,
|
||||
table_entry,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SOURCE = ROOT / "resources" / "data_cost_input_value" / "pum_forest_2026.json"
|
||||
OUT_DIR = ROOT / "resources" / "data_work_item_master"
|
||||
#: 불변 열쇠 장부 — **판이 붙지 않는다.** 판마다 새로 짓는 산출물이 아니라 대대로 잇는 장부다.
|
||||
KEY_REGISTRY = OUT_DIR / "work_item_keys.json"
|
||||
|
||||
SCHEMA_VERSION = "1.0"
|
||||
CODE_PREFIX = "FP" # Forest Pumsem — 공종코드 접두. 품셈 절 번호를 그대로 싣는다.
|
||||
|
||||
# ── 형태 판정 ────────────────────────────────────────────────────────────
|
||||
# 헤더·비고 문자열에서 찾는 표지. 앞의 것이 먼저 걸린다(생산량형이 더 좁은 표현이라 우선).
|
||||
PRODUCTIVITY_MARKS = (
|
||||
"㎥/hr",
|
||||
"m3/hr",
|
||||
"㎡/hr",
|
||||
"본/hr",
|
||||
"/1인/1일",
|
||||
"/인/1일",
|
||||
"인/1일",
|
||||
"작업능력",
|
||||
"ha당 평균 작업량",
|
||||
"ha당 집재재적",
|
||||
)
|
||||
REQUIREMENT_MARKS = (
|
||||
"소요인력",
|
||||
"소요량",
|
||||
"당 주입량",
|
||||
"단위수량",
|
||||
"수 량",
|
||||
"수량",
|
||||
"인/km",
|
||||
"인/ha",
|
||||
"㏊당",
|
||||
"ha당",
|
||||
"당 소요",
|
||||
)
|
||||
# 값이 공종 품이 아니라 계산식 파라미터인 표. 공종으로 세우지 않는다.
|
||||
COEFFICIENT_MARKS = ("손료계수", "시간당손료계수", "기계손료")
|
||||
# 기계 시공능력 공식(Q = 3600·qo·K·f·E / Cm)의 파라미터 기호. 이 기호만 있는 표는 계수표다.
|
||||
COEFFICIENT_ROW_KEYS = {"K", "f", "E", "Cm", "㎝(sec)", "q", "qo", "Q", "V", "L"}
|
||||
# 품셈 1장(적용기준)·할증·공제·단위 표준 — 공종이 아니라 **기준표**다.
|
||||
# ⚠ **「단 위」를 뺐다** (2026-09-07). 원래 겨냥은 품셈 1-2-2 「단위 표준」 표였는데,
|
||||
# **1장은 chapter 규칙이 이미 참조로 거르므로** 이 표지는 넓기만 했다. 그 탓에
|
||||
# **41건이 「헤더 '단 위'」 하나로 참조가 되어 버려지고 있었고**, 그 안에
|
||||
# **돌쌓기(장비) 13-4-5·13-4-2**(우리 매핑이 실제로 쓰는 코드) · 12-2 표면 마무리 ·
|
||||
# 9-19-1 면고르기 · 9-20 뿌리다듬기 · 13-8 막돌쌓기 같은 **명백한 공종**이 섞여 있었다.
|
||||
# 「단위 열이 있다」는 소요량표의 흔한 모양이지 참조표의 표지가 아니다.
|
||||
REFERENCE_MARKS = ("할증률", "할인", "공제율", "적재량", "지위")
|
||||
# 값 대신 「어디를 따르라」고만 적은 표. 품이 아니므로 공종으로 세우지 않는다.
|
||||
REFERENCE_CELL_MARKS = ("별도계상", "구역화물", "적용한다", "따른다", "준용")
|
||||
# 품셈이 실어 둔 **빈 단가산출서 서식** — 값이 없는 예시 양식이다(2026-09-08 ㉙, 서브가 찾음).
|
||||
# 첫 줄이 「위치 및 임·소반」·「위치 및 면적」이고 다음 줄이 「구분 | 작업량 | 단위품 |
|
||||
# 소요품 | 단가」인 모양. 채워 넣으라고 둔 칸이라 **자원도 값도 없다.**
|
||||
# ⚠ 이것을 공종으로 세우면 「값이 있는데 안 서는 자리」로 오해된다 — 애초에 값이 없다.
|
||||
# 서브가 그 표들 탓에 못 맞춘 자원이 882 → 497 로 부풀어 있었다.
|
||||
# ⚠ **두 조건을 다 만족할 때만** 본다 — 「구분」·「단가」는 정상 표에도 흔한 낱말이라
|
||||
# 하나만 보면 정상 표를 지운다(오늘 그 병을 열한 번 겪었다).
|
||||
#: 표지는 **머리글에 그대로 적혀 있다** — 「ha당 참나무시들음병방제 **단가산출서(예시)**」.
|
||||
#: 낱말 하나로 충분히 좁다(정상 표 머리글에 「단가산출서」가 올 일이 없다).
|
||||
BLANK_FORM_MARK = "단가산출서"
|
||||
#: 머리글이 비어 있는 판을 대비한 보조 표지 — **두 조건을 다 만족할 때만** 본다.
|
||||
BLANK_FORM_HEAD_MARKS = ("위치및임소반", "위치및면적")
|
||||
BLANK_FORM_BODY_MARKS = ("단위품", "소요품")
|
||||
# 직종이 값의 주인인 표 = 소요량형. `보통인부(인)`·`콘크리트공(인)` 처럼 `(인)` 이 붙는다.
|
||||
# ⚠ 원문에 `인 부` 처럼 낱말 사이 공백이 있어, 비교 전에 공백을 모두 지운다(`squeeze`).
|
||||
OCCUPATION_RE = re.compile(
|
||||
r"\((?:인|조|인/일|인·일)\)|인부|기능공|운전사|특별인부|보통인부|콘크리트공|철근공|石工|석공|목공|용접공"
|
||||
)
|
||||
# 재료 소요량표의 값 단위. 직종이 없어도 이 단위가 값 열에 오면 소요량형이다.
|
||||
MATERIAL_UNIT_MARKS = ("(kg)", "(㎏)", "(개)", "(매)", "(본)", "(ℓ)", "(L)", "(㎥)", "(㎡)", "(m)")
|
||||
|
||||
# ⚠ **첫 칸이 분류 딱지이고 이름이 둘째 칸인 표** (2026-09-07 서브 창이 자기 파싱에서 잡은 모양).
|
||||
# `['자재', 'PVC 지수판(200×5)', 'm', '1.04']` 처럼 첫 칸이 값의 이름이 아니라 갈래다.
|
||||
# 그런 표는 직종이 넷째·다섯째 행에 있어 **첫 세 행만 보는 판정에 안 걸렸고**, 12장 임도
|
||||
# 구조물 표 9건이 통째로 `undetermined` 로 빠져 있었다. 딱지를 알아보고 다시 본다.
|
||||
CLASSIFICATION_TAGS = frozenset(
|
||||
{
|
||||
"자재",
|
||||
"재료",
|
||||
"재료비",
|
||||
"자재비",
|
||||
"잡재료",
|
||||
"장비",
|
||||
"기계",
|
||||
"인력",
|
||||
"노무",
|
||||
"노무비",
|
||||
"인건비",
|
||||
"설치비",
|
||||
"경비",
|
||||
"공구손료",
|
||||
}
|
||||
)
|
||||
# 딱지 가운데 **품이 붙는 쪽**. 이 딱지가 있으면 그 표는 소요량형이다.
|
||||
RESOURCE_TAGS = frozenset({"자재", "재료", "잡재료", "장비", "기계", "인력", "노무", "공구손료"})
|
||||
# 값이 아니라 **다른 값의 몇 %** 라고만 적은 표. 품이 아니므로 참조로 둔다.
|
||||
RATIO_DIRECTIVE_MARKS = ("재료비의", "주재료비의", "회기준")
|
||||
|
||||
|
||||
def sha256_of(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def norm(text: Any) -> str:
|
||||
"""공백을 하나로 줄인 문자열. `None` 은 빈 문자열."""
|
||||
return " ".join(str(text).split()) if text is not None else ""
|
||||
|
||||
|
||||
def parse_toc(rows: list[list[str]]) -> list[dict[str, Any]]:
|
||||
"""목차표(F0001) → 계층 목록. 장(제n장)·절(n-n)·항(n-n-n)·목(n-n-n-n)."""
|
||||
nodes: list[dict[str, Any]] = []
|
||||
@@ -159,29 +87,76 @@ def parse_toc(rows: list[list[str]]) -> list[dict[str, Any]]:
|
||||
else:
|
||||
continue # 부록 등 — 공종 계층이 아니다.
|
||||
order += 256 # STmate 의 SORTCODE 관례(256 간격) — 중간 삽입 여유.
|
||||
parts = number.split("-")
|
||||
nodes.append(
|
||||
{
|
||||
"work_item_code": f"{CODE_PREFIX}-" + "-".join(p.zfill(2) for p in parts),
|
||||
"number": number,
|
||||
"name": name,
|
||||
"level": len(parts),
|
||||
"parent_code": (
|
||||
f"{CODE_PREFIX}-" + "-".join(p.zfill(2) for p in parts[:-1])
|
||||
if len(parts) > 1
|
||||
else None
|
||||
),
|
||||
"sort_order": order,
|
||||
"tables": [],
|
||||
}
|
||||
)
|
||||
nodes.append({"number": number, "name": name, "sort_order": order, "tables": []})
|
||||
_set_number(nodes[-1], number)
|
||||
return nodes
|
||||
|
||||
|
||||
def _set_number(node: dict[str, Any], number: str) -> None:
|
||||
"""번호에서 코드·단계·윗줄을 셈 — 목차를 읽을 때와 본문 번호로 바로잡을 때 한 벌."""
|
||||
parts = number.split("-")
|
||||
node.update(
|
||||
{
|
||||
"work_item_code": f"{CODE_PREFIX}-" + "-".join(p.zfill(2) for p in parts),
|
||||
"number": number,
|
||||
"level": len(parts),
|
||||
"parent_code": (
|
||||
f"{CODE_PREFIX}-" + "-".join(p.zfill(2) for p in parts[:-1])
|
||||
if len(parts) > 1
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
#: 본문 절 제목 — 「12-17-3. 무근진동기 제외」 꼴(번호 + 마침표 + 이름)만. 문장 조각은 안 봄.
|
||||
_BODY_TITLE_RE = re.compile(r"^\s*(\d+(?:-\d+){0,3})\.\s+(.+)$")
|
||||
|
||||
|
||||
def renumber_from_body(
|
||||
nodes: list[dict[str, Any]], tables: list[dict[str, Any]]
|
||||
) -> list[dict[str, str]]:
|
||||
"""⚠ **목차 오기**를 본문 절 번호로 바로잡음 — 원문이 또렷하면 원문(2026-09-17 코덱스 원문 대조 · 브레인).
|
||||
|
||||
품셈 목차가 「12-17-2 무근진동기 제외」(본문 12-17-3) · 「12-24-1 지수판 설치」(본문 12-27-1) 로
|
||||
**같은 번호를 두 번** 적었음. 목차만 믿으면 뒤 줄이 앞 표를 덮어써 이름과 표가 어긋난 줄이 서고
|
||||
진짜 표(F0364·F0375)가 orphan 으로 사라짐 — 재료·노무·장비가 있는 원가 공종이라 금액이 빠짐.
|
||||
⚠ 겹친 번호의 줄이고 · 본문에 같은 이름 제목이 있고 · 그 본문 번호가 목차에 없을 때만 고침.
|
||||
셋 중 하나라도 아니면 그대로 둠(겹친 채 남아 `toc_duplicate_rows` 와 시험이 드러냄).
|
||||
"""
|
||||
body: dict[str, set[str]] = {}
|
||||
for table in tables:
|
||||
if found := _BODY_TITLE_RE.match(norm(table.get("section"))):
|
||||
body.setdefault("".join(found.group(2).split()), set()).add(found.group(1))
|
||||
count: dict[str, int] = {}
|
||||
for node in nodes:
|
||||
count[node["number"]] = count.get(node["number"], 0) + 1
|
||||
fixed = []
|
||||
for node in nodes:
|
||||
numbers = body.get("".join(node["name"].split()), set())
|
||||
if count[node["number"]] < 2 or node["number"] in numbers:
|
||||
continue
|
||||
candidates = sorted(numbers - set(count))
|
||||
if len(candidates) != 1:
|
||||
continue
|
||||
printed = node["number"]
|
||||
_set_number(node, candidates[0])
|
||||
count[printed] -= 1
|
||||
count[node["number"]] = 1
|
||||
fixed.append({"name": node["name"], "toc_number": printed, "body_number": node["number"]})
|
||||
return fixed
|
||||
|
||||
|
||||
#: ⚠ [주] 속 **인용** — 「‘13-3. 기초다짐 및 뒤채움’ 항을 적용한다」(13-4-1 [주]④ · 13-5-2 [주]⑤).
|
||||
#: 「번호. 이름」 꼴이라 제목으로 읽혀 표 셋(F0405·F0406·F0418)이 13-3 에 붙고 없는 갈래 3줄이 섰음
|
||||
#: (2026-09-17 서브 훑기 · 브레인). 닫는 따옴표 뒤에 「항·을·를」이 오면 인용.
|
||||
_CITATION_RE = re.compile(r"[’”]\s*(?:항|을|를)")
|
||||
|
||||
|
||||
def _is_section_title(section: str, by_number: dict[str, dict[str, Any]]) -> bool:
|
||||
"""진짜 절 제목인가 — 「번호. 이름」, 또는 마침표 없이 번호 뒤 글이 목차 절 이름과 같음."""
|
||||
"""진짜 절 제목인가 — 「번호. 이름」(인용 아님), 또는 마침표 없이 번호 뒤 글이 목차 절 이름과 같음."""
|
||||
if re.match(r"^\s*\d+(?:-\d+){0,3}\.\s", section):
|
||||
return True
|
||||
return not _CITATION_RE.search(section)
|
||||
number = section_number(section)
|
||||
node = by_number.get(number) if number else None
|
||||
if node is None:
|
||||
@@ -190,400 +165,47 @@ def _is_section_title(section: str, by_number: dict[str, dict[str, Any]]) -> boo
|
||||
return "".join(tail.split()) == "".join(str(node.get("name") or "").split())
|
||||
|
||||
|
||||
#: 목차 이름과 본문 제목이 **글자만** 다른 자리(「깍기」·「깎기」 · 「㎝」·「cm」)를 같게 봄 — 이름 닮음 잴 때만.
|
||||
_NAME_GLYPHS = (("㎝", "cm"), ("․", "·"), ("ㆍ", "·"), ("~", "~"), ("∼", "~"), ("깎", "깍"))
|
||||
#: 이름 닮음 아래턱 — 부록 사례(「4-1. 소나무재선충병방제」 ↔ 목차 4-1 수확베기)는 0.09 이하 ·
|
||||
#: 진짜 제목의 글자 차이(「비탈면 고르기」 ↔ 「비탈면 면고르기」 0.96 · 「면벽」 ↔ 「면벅」 0.5)는 0.34 이상(2026-09-17 전수).
|
||||
NAME_LIKENESS_FLOOR = 0.3
|
||||
|
||||
|
||||
def _plain(name: str) -> str:
|
||||
text = "".join(str(name or "").split())
|
||||
for glyph, same in _NAME_GLYPHS:
|
||||
text = text.replace(glyph, same)
|
||||
return text
|
||||
|
||||
|
||||
def title_names_node(section: str, node: dict[str, Any]) -> bool:
|
||||
"""「번호. 이름」 제목의 이름이 그 번호 목차 줄 이름을 가리키는가 — 번호만 겹친 **딴 문서 표**를 거름.
|
||||
|
||||
⚠ 부록 사례 표 일곱(F0455·F0456·F0472~F0476)이 본문 절 번호와 겹쳐 그 절에 붙어
|
||||
없는 공종 줄(4-1 수확베기 ← 소나무재선충병방제)이 섰음(2026-09-17 브레인 헛단위). 못 붙인 표로 목록에 남김.
|
||||
"""
|
||||
found = re.match(r"^\s*\d+(?:-\d+){0,3}\.\s+(.+)$", section)
|
||||
if found is None:
|
||||
return True # 마침표 없는 제목은 `_is_section_title` 이 이미 목차 이름과 같은지 봤음
|
||||
title, name = _plain(found.group(1)), _plain(node.get("name"))
|
||||
if title.startswith(name) or name.startswith(title):
|
||||
return True
|
||||
return difflib.SequenceMatcher(None, title, name).ratio() >= NAME_LIKENESS_FLOOR
|
||||
|
||||
|
||||
def section_number(section: str) -> str | None:
|
||||
"""`"9-3-1. 인력"` → `"9-3-1"`. 번호가 없으면 `None`."""
|
||||
m = re.match(r"^\s*(\d+(?:-\d+){0,3})[.\s]", section + " ")
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def detect_form(table: dict[str, Any], chapter: str | None) -> tuple[str, str]:
|
||||
"""`(pum_form, 근거 문구)`. 판정 못 하면 `("undetermined", 이유)`.
|
||||
|
||||
순서가 뜻을 가진다 — 좁은 표지부터 본다. 계수·기준표를 먼저 걸러 내야
|
||||
「작업능력」 같은 흔한 낱말이 기준표를 공종으로 오인하지 않는다.
|
||||
"""
|
||||
hay = " ".join(norm(h) for h in table.get("headers", []))
|
||||
keys = [norm(r[0]) for r in table.get("rows", []) if r]
|
||||
head_rows = [norm(c) for r in table.get("rows", [])[:3] for c in r]
|
||||
first_rows = " ".join(head_rows)
|
||||
both = f"{hay} || {first_rows}"
|
||||
squeezed = both.replace(" ", "") # `인 부` → `인부`. 원문 자간 공백을 지운 뒤 비교한다.
|
||||
|
||||
for mark in COEFFICIENT_MARKS:
|
||||
if mark in hay:
|
||||
return "coefficient", f"헤더 '{mark}'"
|
||||
if keys and set(keys) <= COEFFICIENT_ROW_KEYS:
|
||||
return "coefficient", f"행 키가 시공능력 공식 기호뿐 {sorted(set(keys))}"
|
||||
if keys and all(re.fullmatch(r"[a-zA-Zqf][₀-₉0-9]?", k) for k in keys):
|
||||
return "coefficient", f"행 키가 기호뿐 {sorted(set(keys))}"
|
||||
|
||||
# 1장은 적용기준 장 자체다 — 공종이 아니라 기준표로 둔다(PLAN 8-14 「목록은 규정에서」).
|
||||
if chapter == "1":
|
||||
return "reference", "품셈 제1장(적용기준)"
|
||||
for mark in REFERENCE_MARKS:
|
||||
if mark in hay:
|
||||
return "reference", f"헤더 '{mark}'"
|
||||
for mark in REFERENCE_CELL_MARKS:
|
||||
if mark in squeezed:
|
||||
return "reference", f"'{mark}' — 값이 아니라 참조 지시"
|
||||
|
||||
# 빈 단가산출서 서식 — 머리글 표지가 정본, 머리 두 줄은 보조.
|
||||
if BLANK_FORM_MARK in hay.replace(" ", ""):
|
||||
return "reference", f"'{BLANK_FORM_MARK}' — 채워 넣으라고 둔 빈 서식"
|
||||
head2 = "".join(
|
||||
norm(str(c)) for row in (table.get("rows") or table.get("raw_row") or [])[:2] for c in row
|
||||
)
|
||||
for mark in ("․", "·", "ㆍ", " "):
|
||||
head2 = head2.replace(mark, "")
|
||||
if any(m in head2 for m in BLANK_FORM_HEAD_MARKS) and any(
|
||||
m in head2 for m in BLANK_FORM_BODY_MARKS
|
||||
):
|
||||
return "reference", "품셈이 실어 둔 빈 단가산출서 서식 — 채워 넣으라고 둔 칸"
|
||||
|
||||
# ⚠ 생산량형을 **직종보다 먼저** 본다. 「작업능력(㎥/hr)」 표의 비고란에 흔히
|
||||
# 「보통인부 1인/일」이 붙어 있어, 직종을 먼저 보면 생산량형이 소요량형으로 뒤집힌다.
|
||||
# 그 뒤집힘이 곧 PLAN 8-6 이 경고한 「값이 조용히 반대로 들어가는」 사고다.
|
||||
for mark in PRODUCTIVITY_MARKS:
|
||||
if mark in hay:
|
||||
return "productivity", f"헤더 '{mark}'"
|
||||
|
||||
# 직종이 값의 주인이면 소요량형이다 — 「보통인부(인) 0.16」 은 ㎥당 품이다.
|
||||
if OCCUPATION_RE.search(squeezed):
|
||||
return "requirement", "직종 표기((인)·인부·공)"
|
||||
for mark in MATERIAL_UNIT_MARKS:
|
||||
if mark in hay:
|
||||
return "requirement", f"값 단위 '{mark}'"
|
||||
|
||||
for mark in PRODUCTIVITY_MARKS:
|
||||
if mark in both:
|
||||
return "productivity", f"본문 '{mark}'"
|
||||
for mark in REQUIREMENT_MARKS:
|
||||
if mark in both:
|
||||
return "requirement", f"'{mark}'"
|
||||
|
||||
# ⚠ 첫 칸이 분류 딱지인 표 — **맨 마지막에 본다.** 앞의 판정을 흔들지 않으려는 자리다.
|
||||
tags = [norm(row[0]).replace(" ", "") for row in table.get("rows", []) if row]
|
||||
if any(tag in CLASSIFICATION_TAGS for tag in tags):
|
||||
# ⚠ **소요량을 먼저 본다.** 비율 지시를 먼저 보면 `잡재료비(재료비의) 5 %` 한 줄 때문에
|
||||
# 강관동바리(내관 0.38본·형틀목공 0.07인) 같은 **멀쩡한 소요량표가 참조로 넘어간다**
|
||||
# — 만들다 실제로 걸린 자리다.
|
||||
# 값이 실제로 있는 표만 공종으로 세운다. 이름만 있고 수치가 없으면 판정하지 않는다.
|
||||
has_number = any(
|
||||
re.fullmatch(r"[\d,]+(?:\.\d+)?", norm(c)) for r in table.get("rows", []) for c in r
|
||||
)
|
||||
if has_number and any(tag in RESOURCE_TAGS for tag in tags):
|
||||
return "requirement", f"분류 딱지 {sorted({t for t in tags if t in RESOURCE_TAGS})}"
|
||||
body = " ".join(norm(c) for r in table.get("rows", []) for c in r).replace(" ", "")
|
||||
for mark in RATIO_DIRECTIVE_MARKS:
|
||||
if mark in body:
|
||||
return "reference", f"분류 딱지 표의 '{mark}' — 값이 아니라 비율 지시"
|
||||
|
||||
return "undetermined", "헤더·첫 행에 단위·밑수·직종 표지 없음"
|
||||
|
||||
|
||||
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건이 잘못 잡혔다.
|
||||
#: ⚠ `(단위: 인/㎡당)` 꼴 — **분모가 밑수**다. 값의 단위(인)를 밑수로 읽으면 뜻이 뒤집힌다.
|
||||
#: ⚠ 「단위:」 없이 `(인/100m당)` 으로만 적은 표가 22 곳이다(조림 5장·단끊기 5-16-1 등, 2026-09-13).
|
||||
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
|
||||
|
||||
#: 표 **아래 [주]** 에만 밑수가 적힌 자리 — 「목재의 손율은 **1개소 사용당** 50%로 한다」.
|
||||
#: ⚠ **아주 좁게 잡는다.** 「N개소 **사용** 당」이라는 표기는 품셈 전문에 **딱 두 곳**뿐이고
|
||||
#: (11-2 비탈 규준틀 · 11-3 수평 규준틀) 둘 다 개소로 세는 표다. 「개소당 평균면적」 같은
|
||||
#: 흔한 말은 「사용」이 없어 안 걸린다 — 넓히면 조림 2장이 통째로 걸린다(실측 19건).
|
||||
#: ⚠ 뜻으로도 개소가 맞다 — [주]② 가 「본 품은 …한 **비탈규준틀**의 제작·도색·가설·철거를
|
||||
#: 포함한 것」이라 **규준틀 하나**를 말하고, [주]① 이 「20m마다 **설치**」라 센다.
|
||||
SOURCE_BASIS_NOTE_RE = re.compile(r"([\d,]*\.?\d*)\s*개소\s*사용\s*당")
|
||||
#: 표 아래로 몇 줄까지 볼 것인가. [주] 는 표 바로 밑에 붙는다.
|
||||
SOURCE_NOTE_LOOKAHEAD = 8
|
||||
|
||||
|
||||
#: ⚠⚠ **「인」은 품의 단위이지 공종 밑수가 아니다**(2026-09-09 원문 대조로 넷이 오독으로 드러남).
|
||||
#: 8-6-2 드론방제·8-6-3 지상방제 「(단위 : 인)」 — 그 「인」은 **소요인력**이고 공종 밑수는
|
||||
#: ha 다. 13-2-4 야면석 채집 「(단위: 인 당)」 — 표 안이 **㎡당·㎥당** 두 줄이다.
|
||||
#: ⇒ **원문에 「<숫자>인당」이 그대로 있을 때만** 밑수로 인정한다.
|
||||
#: ⚠ **넓게 「인」을 통째로 버리면 2-2-5 천공기가 사라진다** — 그 표는 셀 안에 「천공인부
|
||||
#: **1인당** 1대」로 숫자가 붙어 있다. 그래서 「숫자가 붙었나」로 좁게 가른다.
|
||||
PERSON_UNITS = {"인", "공"}
|
||||
|
||||
|
||||
def person_basis_ok(raw_quantity: str | None, unit: str | None) -> bool:
|
||||
"""「인」 계열 밑수를 인정할 것인가 — **숫자가 붙어 있을 때만** 참."""
|
||||
if unit not in PERSON_UNITS:
|
||||
return True
|
||||
return bool((raw_quantity or "").strip())
|
||||
|
||||
|
||||
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(",", "")
|
||||
if not person_basis_ok(raw, m.group(2)):
|
||||
continue # 「인」에 숫자가 안 붙었다 — 품의 단위이지 밑수가 아니다
|
||||
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(",", "")
|
||||
if not person_basis_ok(raw, unit):
|
||||
continue # 위와 같음 — 「(단위 : 인)」·「(단위: 인 당)」은 밑수가 아니다
|
||||
try:
|
||||
quantity = float(raw) if raw else 1.0
|
||||
except ValueError:
|
||||
quantity = 1.0
|
||||
return quantity, unit
|
||||
|
||||
# 표 **아래 [주]** 도 본다 — 위에 아무것도 없을 때만. 규준틀 둘이 그 자리다.
|
||||
# ⚠ 다음 표(`|`)나 다음 절(`###`)을 만나면 멈춘다 — 남의 [주]를 물어 오면 조용히 틀린다.
|
||||
for index in range(line_no, min(len(lines), line_no + SOURCE_NOTE_LOOKAHEAD)):
|
||||
text = lines[index].strip()
|
||||
if text.startswith("###"):
|
||||
break
|
||||
if m := SOURCE_BASIS_NOTE_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, "개소"
|
||||
return None, None
|
||||
|
||||
|
||||
#: ⚠ **밑수가 절 이름에만 있는 표** — 「4-2-2. 1,000㎡당」·「4-2-1. 100본당」(2026-09-13 판정).
|
||||
#: 본문·표 안 어디에도 「(단위: …)」가 없어 밑수가 비어 있었다(B09 가 막아 금액은 안 섰음).
|
||||
#: ⚠ **이름 전체가 「수 + 단위 + 당」일 때만** 뽑는다 — 「나무주사(100본당)」처럼 섞인 것은
|
||||
#: 본문이 이미 주고, 「자재의 1회당 표준 운반량」 같은 설명문은 밑수가 아니다. 짐작하지 않는다.
|
||||
NAME_BASIS_RE = re.compile(
|
||||
r"^([\d,]+(?:\.\d+)?)\s*(㎥|㎡|㏊|ha|㎞|km|m|매|본|개소|개|주|kg|㎏|톤|ton)\s*당$"
|
||||
)
|
||||
|
||||
|
||||
def basis_from_name(name: str) -> tuple[float | None, str | None]:
|
||||
"""절 이름이 곧 밑수인 경우만 `(수, 단위)`. 아니면 `(None, None)`."""
|
||||
found = NAME_BASIS_RE.match(norm(name))
|
||||
if found is None:
|
||||
return None, None
|
||||
try:
|
||||
return float(found.group(1).replace(",", "")), found.group(2)
|
||||
except ValueError:
|
||||
return None, None
|
||||
|
||||
|
||||
def detect_basis(table: dict[str, Any]) -> tuple[float | None, str | None]:
|
||||
"""「100㎥당」 같은 밑수. 없으면 `(None, None)` — 단위당 1 로 단정하지 않는다.
|
||||
|
||||
⚠⚠ **칸 하나 안에서만 본다**(2026-09-09). 여러 칸을 이어 붙여 보면 **앞 칸의 값**과
|
||||
**뒤 칸의 단위**가 붙어 없는 밑수가 생긴다 — 13-2-4 야면석 채집이 그랬다:
|
||||
행 ① 인 부 | ㎡당 | 0.11 | 0.17 | 0.22 | 0.28 | **0.36**
|
||||
행 ② **㎥당** | 0.60 | …
|
||||
이어 붙이면 「0.36 ㎥당」이 되어 **밑수 0.36㎥** 라는 값이 선다(원문에 없는 값).
|
||||
⚠ 그 표는 **㎡당·㎥당 두 줄**이라 애초에 하나로 못 정한다 — 「미확보」가 정직하다.
|
||||
⚠ **「인」은 여기서도 숫자가 붙어야 인정한다** — `BASIS_RE` 가 숫자를 요구하므로
|
||||
2-2-5 「천공인부 **1인당** 1대」는 그대로 서고 「(단위 : 인)」은 안 선다.
|
||||
"""
|
||||
cells = [norm(h) for h in table.get("headers", []) or []]
|
||||
cells += [norm(c) for r in (table.get("rows", []) or [])[:2] for c in r]
|
||||
for cell in cells:
|
||||
if not cell:
|
||||
continue
|
||||
if m := BASIS_RE.search(cell):
|
||||
if not person_basis_ok(m.group(1), m.group(2)):
|
||||
continue
|
||||
try:
|
||||
return float(m.group(1).replace(",", "")), m.group(2)
|
||||
except ValueError:
|
||||
return None, m.group(2)
|
||||
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"}
|
||||
|
||||
|
||||
# ⚠ **값 자리에 숫자가 아니라 식이 적힌 칸** (2026-09-07 서브 창 제보로 확인).
|
||||
# `0.2 × 30%`(기초잡석 소할) · `(0.2+0.26)/2` · `1/1.3` 처럼 계산이 그대로 적혀 있다.
|
||||
# 형태 판정은 통과하는데 **값이 숫자로 안 읽혀 그 성분이 조용히 빠진다** —
|
||||
# `partial_ratio` 와 같은 병인데 배분율 딱지가 없어 아무 검사에도 안 걸렸다.
|
||||
# ⚠ 여기서 **식을 계산하지 않는다.** 뜻을 잘못 읽으면 조용히 틀리므로 **드러내기만** 한다.
|
||||
_PLAIN_NUMBER_RE = re.compile(r"^[\d,]+(?:\.\d+)?$")
|
||||
_CALC_CELL_RE = re.compile(r"^[\d,.\s()×xX*/÷+\-%]+$")
|
||||
_CALC_MAX_LEN = 22
|
||||
|
||||
|
||||
def expression_cells(table: dict[str, Any]) -> list[str]:
|
||||
"""값 자리에 식이 적힌 칸 목록. 없으면 빈 목록."""
|
||||
found: list[str] = []
|
||||
for row in table.get("rows", []):
|
||||
for cell in row:
|
||||
text = norm(cell)
|
||||
if not text or len(text) > _CALC_MAX_LEN or _PLAIN_NUMBER_RE.match(text):
|
||||
continue
|
||||
if (
|
||||
_CALC_CELL_RE.match(text)
|
||||
and re.search(r"\d", text)
|
||||
and re.search(r"[×xX*/÷+]", text)
|
||||
):
|
||||
found.append(text)
|
||||
# 순서를 지키되 중복은 지운다 — 같은 식이 여러 줄에 반복된다.
|
||||
seen: list[str] = []
|
||||
for item in found:
|
||||
if item not in seen:
|
||||
seen.append(item)
|
||||
return seen
|
||||
|
||||
|
||||
# ⚠ **작업조 표** — 「형틀목공 4인 + 보통인부 1인 / 시공량 25·35·40㎡」처럼
|
||||
# **인원과 시공량**으로 적힌 표다. 그 「4」는 소요량이 아니라 **작업조 인원**이라
|
||||
# 행-자원으로 그냥 읽으면 **35배 부푼다**(유로폼 12-38-3 이 그 표다).
|
||||
# ⚠ 값을 바꾸지 않고 **표시만** 한다 — 나누는 것은 받는 쪽 몫이다.
|
||||
CREW_QUANTITY_MARKS = ("시공량", "작업량", "1일작업량")
|
||||
|
||||
|
||||
# ⚠ **이름에 자간 공백이 든 기종·직종** — 같은 표 묶음 안에서도 표기가 갈린다
|
||||
# (`13-6-1` 은 「굴 삭 기 (무한궤도)」, 바로 옆 `13-6-2` 는 「굴착기 (무한궤도)」).
|
||||
# 받는 쪽이 이름으로 자원을 찾으므로 **표기가 갈리면 그 줄이 통째로 빠진다.**
|
||||
# ⚠ **여기서 이름을 고치지 않는다** — 정규화는 값을 살리지만 **잘못된 줄도 함께 살린다**
|
||||
# (서브 창 실례: 이름 정규화 직후 버킷계수 `K` 를 소요량으로 읽어 시간당 사용료가 이중).
|
||||
# 깃발만 실어 받는 쪽이 대조하게 한다.
|
||||
_SPACED_NAME_RE = re.compile(r"^(?=.*\S\s\S)[가-힣](?:\s+[가-힣])+")
|
||||
#: 시공능력 공식 파라미터가 값 자리에 온 줄 — **소요량이 아니다.** 그냥 읽으면 이중계상.
|
||||
_FORMULA_KEYS = frozenset({"K", "k", "f", "E", "Cm", "q", "qo", "Q", "㎝(sec)"})
|
||||
#: 자원 줄임을 알리는 단위 칸. 이것과 수치가 함께 있어야 자원으로 본다.
|
||||
_RESOURCE_UNITS = frozenset(
|
||||
{"인", "h", "hr", "시간", "대", "매", "본", "개", "kg", "㎏", "㎥", "㎡", "m", "ℓ", "톤", "ton"}
|
||||
)
|
||||
|
||||
|
||||
def spaced_names(table: dict[str, Any]) -> list[str]:
|
||||
"""자간 공백이 든 **자원 이름**. 표기가 갈리는 자리를 드러낸다.
|
||||
|
||||
⚠ **좁게 잡는다.** 「단 위」·「모 래」 같은 머리글·재료명까지 걸면 101건이 되어
|
||||
목록이 잡음이 되고, 잡음이 되면 아무도 안 본다(오늘 아홉 번 겪은 병).
|
||||
**그 줄에 단위 칸과 수치가 함께 있는 것**만 자원 줄로 본다.
|
||||
"""
|
||||
found: list[str] = []
|
||||
for row in table.get("rows", []):
|
||||
if not row:
|
||||
continue
|
||||
name = norm(row[0])
|
||||
if not name or not _SPACED_NAME_RE.match(name):
|
||||
continue
|
||||
rest = [norm(cell) for cell in row[1:]]
|
||||
has_unit = any(cell in _RESOURCE_UNITS for cell in rest)
|
||||
has_number = any(_PLAIN_NUMBER_RE.match(cell) for cell in rest if cell)
|
||||
if has_unit and has_number and name not in found:
|
||||
found.append(name)
|
||||
return found
|
||||
|
||||
|
||||
def formula_rows(table: dict[str, Any]) -> list[str]:
|
||||
"""값 자리에 시공능력 공식 기호가 온 줄. 자원으로 세면 이중계상이다."""
|
||||
found: list[str] = []
|
||||
for row in table.get("rows", []):
|
||||
for cell in row:
|
||||
text = norm(cell)
|
||||
if text in _FORMULA_KEYS and text not in found:
|
||||
found.append(text)
|
||||
return found
|
||||
|
||||
|
||||
# ⚠ **규격 표기에 쓰이는 특수문자** — 원문이 한 종류로 안 쓴다(2026-09-07 실측).
|
||||
# 물결표만 셋이다: `∼`(U+223C) 662회 · `~`(U+FF5E) 459회 · `~`(U+007E) 2회.
|
||||
# 곱셈표도 `×`(U+00D7) 97회 · `x`(U+0078) 4회로 갈린다.
|
||||
# **두 창이 각자 갈래 키를 조립하면 글자 하나로 영영 안 맞는다** — 그래서 우리는
|
||||
# 키를 조립하지 않고 **저장 원본값만** 보낸다(인계 계약). 여기서는 **표시만** 한다.
|
||||
# ⚠ 값을 고치지 않는다 — 원문 표기를 흡수하는 것은 **원문을 읽는 쪽**의 몫이다.
|
||||
SPECIAL_GLYPHS = {
|
||||
"∼": "∼(U+223C)",
|
||||
"~": "~(U+FF5E)",
|
||||
"~": "~(U+007E)",
|
||||
"×": "×(U+00D7)",
|
||||
"x": "x(U+0078)",
|
||||
"–": "–(U+2013)",
|
||||
"‐": "‐(U+2010)",
|
||||
}
|
||||
|
||||
|
||||
def special_glyphs(table: dict[str, Any]) -> list[str]:
|
||||
"""규격 표기에 쓰인 특수문자 종류. 갈래 키를 맞출 때 대조할 자리."""
|
||||
text = " ".join(norm(cell) for row in table.get("rows", []) for cell in row)
|
||||
return sorted({SPECIAL_GLYPHS[ch] for ch in text if ch in SPECIAL_GLYPHS})
|
||||
|
||||
|
||||
def crew_table(table: dict[str, Any]) -> bool:
|
||||
"""작업조 + 시공량으로 적힌 표인가."""
|
||||
hay = " ".join(norm(h) for h in table.get("headers", []))
|
||||
body = " ".join(norm(c) for row in table.get("rows", []) for c in row)
|
||||
squeezed = (hay + " " + body).replace(" ", "")
|
||||
if not any(mark in squeezed for mark in CREW_QUANTITY_MARKS):
|
||||
return False
|
||||
return bool(OCCUPATION_RE.search(squeezed))
|
||||
|
||||
|
||||
def resource_shares(table: dict[str, Any]) -> dict[str, float]:
|
||||
"""`{인력: 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 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]:
|
||||
def build() -> tuple[
|
||||
dict[str, Any],
|
||||
list[dict[str, Any]],
|
||||
list[dict[str, Any]],
|
||||
tuple[dict[str, Any], list[dict[str, str]]],
|
||||
]:
|
||||
data = json.loads(SOURCE.read_text(encoding="utf-8"))
|
||||
# 사람 판정 선언(표 형태·부모 모양)은 절 번호로 적혀 판에 묶임 — 판이 다르면 멈춤(명세 17장).
|
||||
from common_util.common_util_pum_edition import require_code_edition
|
||||
@@ -599,16 +221,12 @@ def build() -> dict[str, Any]:
|
||||
break
|
||||
toc_table = next(t for t in tables if t["table_id"] == "F0001")
|
||||
nodes = parse_toc(toc_table["rows"])
|
||||
toc_corrections = renumber_from_body(nodes, tables)
|
||||
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
|
||||
report = new_report()
|
||||
orphans: list[dict[str, Any]] = []
|
||||
undetermined: list[dict[str, Any]] = []
|
||||
|
||||
last_section = ""
|
||||
for table in tables:
|
||||
@@ -625,88 +243,12 @@ def build() -> dict[str, Any]:
|
||||
section = last_section
|
||||
number = section_number(section)
|
||||
chapter = number.split("-")[0] if number else None
|
||||
form, why = detect_form(table, chapter)
|
||||
# 사람이 가른 형태가 자동 판정을 이긴다 — 표마다 까닭 한 줄(미판정 표 판정, 2026-09-13).
|
||||
form, why = judged_form(table["table_id"], number) or (form, why)
|
||||
# ⚠ **본문이 정본이다.** 표 안을 긁는 쪽은 보조 — 비고에 적힌 다른 기준
|
||||
# (「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 = "본문"
|
||||
# 본문·표 안에 없을 때만 **절 이름**을 본다(4-2-2 「1,000㎡당」) — 이름 전체가 밑수일 때만.
|
||||
if basis_unit is None and number in by_number:
|
||||
name_qty, name_unit = basis_from_name(by_number[number]["name"])
|
||||
if name_unit:
|
||||
basis_qty, basis_unit, basis_source = name_qty, name_unit, "절 이름"
|
||||
if basis_unit:
|
||||
basis_found += 1
|
||||
if basis_quantity_is_grouped(basis_qty):
|
||||
basis_grouped += 1
|
||||
elif form in ("requirement", "productivity") and not crew_table(table):
|
||||
# 참조·계수표는 곱할 값이 아니므로 목록에 넣지 않는다 — 잡음이 되면 안 본다.
|
||||
# 작업조 표도 뺌 — 밑수가 시공량 열(1단위당 = 인원 ÷ 시공량 · B09 CrewOutput)이라
|
||||
# 「N㎡당」 문구가 없어도 곱셈이 안 틀림(12-38-3 설치·해체가 여기 걸려 막혀 있었음 · 2026-09-14 301).
|
||||
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,
|
||||
"source_line": table.get("line"),
|
||||
"pum_form": form,
|
||||
"form_basis": why,
|
||||
"basis_quantity": basis_qty,
|
||||
"basis_unit": basis_unit,
|
||||
# 밑수를 어디서 읽었나 — 본문(정본) / 표 안(보조). 없으면 None.
|
||||
"basis_source": basis_source,
|
||||
"resource_shares": shares,
|
||||
"partial_ratio": partial,
|
||||
# ⚠ 값 자리에 식이 적힌 칸 — 그 성분은 숫자로 안 읽히므로 **금액을 만들면 안 된다**.
|
||||
"expression_cells": expression_cells(table),
|
||||
# ⚠ 작업조 표 — 「4」가 소요량이 아니라 인원이다. 그냥 읽으면 35배 부푼다.
|
||||
"crew_table": crew_table(table),
|
||||
# ⚠ 이름 표기가 갈리는 줄 — 받는 쪽이 이름으로 찾으면 통째로 빠진다.
|
||||
"spaced_names": spaced_names(table),
|
||||
# ⚠ 공식 기호 줄 — 소요량이 아니다. 자원으로 세면 이중계상.
|
||||
"formula_rows": formula_rows(table),
|
||||
# ⚠ 규격 표기 특수문자 — 갈래 키를 맞출 때 대조할 자리(값은 안 고침).
|
||||
"special_glyphs": special_glyphs(table),
|
||||
# 공식 기호가 이 표에 직접 있는가 — 없으면 앞 표에서 물려받는 모양이다.
|
||||
"capacity_formula_here": capacity_formula_pending(table),
|
||||
# 갈래 키 — 부모 모양을 단 뒤 표 읽기가 가른 대로 채움(`_Variants`). 못 가르면 빈칸.
|
||||
"variant_key": [],
|
||||
"condition_note": [norm(h) for h in table.get("headers", []) if norm(h)],
|
||||
"raw_row": table.get("rows", []), # 원문 셀 — B09 자원 축이 읽는다.
|
||||
}
|
||||
if form == "undetermined":
|
||||
undetermined.append(
|
||||
{
|
||||
"pum_table_id": table["table_id"],
|
||||
"section": section,
|
||||
"headers": entry["condition_note"],
|
||||
"first_rows": table.get("rows", [])[:2],
|
||||
"reason": why,
|
||||
}
|
||||
)
|
||||
node = by_number.get(number) if number else None
|
||||
if node is not None and not title_names_node(section, node):
|
||||
node = None # 번호만 겹친 딴 문서 표(부록 사례) — 못 붙인 표 목록으로
|
||||
entry = table_entry(
|
||||
table, section, number, chapter, source_lines, node and node["name"], report
|
||||
)
|
||||
if node is None:
|
||||
orphans.append({"pum_table_id": table["table_id"], "section": section})
|
||||
continue
|
||||
@@ -721,20 +263,77 @@ def build() -> dict[str, Any]:
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Variants import attach_variant_keys
|
||||
|
||||
attach_variant_keys(nodes, data["effective_date"])
|
||||
return finish(
|
||||
data,
|
||||
source=SOURCE,
|
||||
dataset_id="work_item_master_forest",
|
||||
nodes=nodes,
|
||||
tables_total=len(tables) - 1,
|
||||
attached=attached,
|
||||
orphans=orphans,
|
||||
report=report,
|
||||
registry_path=KEY_REGISTRY,
|
||||
toc_corrections=toc_corrections,
|
||||
)
|
||||
|
||||
|
||||
def finish(
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
source: Path,
|
||||
dataset_id: str,
|
||||
nodes: list[dict[str, Any]],
|
||||
tables_total: int,
|
||||
attached: int,
|
||||
orphans: list[dict[str, Any]],
|
||||
report: dict[str, Any],
|
||||
registry_path: Path,
|
||||
toc_corrections: list[dict[str, str]],
|
||||
key_prefix: str = "FW",
|
||||
general_roots: tuple[str, ...] | None = None,
|
||||
policy: dict[str, Any] | None = None,
|
||||
) -> tuple[
|
||||
dict[str, Any],
|
||||
list[dict[str, Any]],
|
||||
list[dict[str, Any]],
|
||||
tuple[dict[str, Any], list[dict[str, str]]],
|
||||
]:
|
||||
"""열쇠·목차 칸을 얹고 산출 한 벌을 짬 — 산림·건설 같은 꼴(`_Const` 도 이리 옴)."""
|
||||
# 불변 열쇠(`FW-00001`) — 목차 번호를 열쇠에서 칸으로 내린다(2026-09-17 브레인 ①).
|
||||
# ⚠ 장부에 없는 목차 코드는 **새 열쇠**를 받는다. 짐작으로 옛 열쇠를 물려주지 않는다.
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import (
|
||||
assign_keys,
|
||||
assign_variant_keys,
|
||||
decorate,
|
||||
load_registry,
|
||||
toc_edition,
|
||||
)
|
||||
|
||||
edition = toc_edition(data.get("sources"), data["effective_date"])
|
||||
registry = load_registry(registry_path, key_prefix)
|
||||
newly_issued, toc_duplicates = assign_keys(nodes, registry, edition=edition)
|
||||
# 갈래도 불변 열쇠 — 갈래 글자를 열쇠에 담으면 원문이 다듬어질 때 열쇠가 갈린다(2026-09-17 브레인).
|
||||
newly_variants = assign_variant_keys(nodes, registry)
|
||||
decorate(nodes, edition=edition, roots=general_roots) # None = 산림 잣대(`axis_policy.json`)
|
||||
role_counts: dict[str, int] = {}
|
||||
for node in nodes:
|
||||
role_counts[node["axis_role"]] = role_counts.get(node["axis_role"], 0) + 1
|
||||
|
||||
src_meta = {
|
||||
"dataset_id": data["dataset_id"],
|
||||
"effective_date": data["effective_date"],
|
||||
"sha256": sha256_of(SOURCE),
|
||||
"file": SOURCE.name,
|
||||
"sha256": sha256_of(source),
|
||||
"file": source.name,
|
||||
}
|
||||
return (
|
||||
{
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"dataset_id": "work_item_master_forest",
|
||||
"dataset_id": dataset_id,
|
||||
"effective_date": data["effective_date"],
|
||||
# 공종 코드(`FP-*` 절 번호)가 묶인 품셈 판 — 자원 축·인계 줄이 이 값을 싣는다.
|
||||
"pum_edition": data["effective_date"],
|
||||
# 목차 번호가 묶인 고시 판. `work_item_code`(=`toc_code`)는 **이 판의 목차 번호**다.
|
||||
"toc_edition": edition,
|
||||
"generated_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
||||
"dataset_version": src_meta,
|
||||
"policy": {
|
||||
@@ -742,32 +341,66 @@ def build() -> dict[str, Any]:
|
||||
"resource_axis_owner": "B09",
|
||||
"no_invented_values": True,
|
||||
"raw_row_preserved": True,
|
||||
# 줄의 열쇠는 `work_item_key`(`FW-…`)다. `work_item_code`·`toc_number` 는
|
||||
# **그 판의 목차 번호**라 개정 때 밀린다 — 열쇠로 쓰지 말 것.
|
||||
"row_key": "work_item_key",
|
||||
"toc_is_column_not_key": True,
|
||||
**(policy or {}),
|
||||
},
|
||||
"stats": {
|
||||
"toc_nodes": len(nodes),
|
||||
"tables_total": len(tables) - 1,
|
||||
"tables_total": tables_total,
|
||||
"tables_attached": attached,
|
||||
"tables_orphan": len(orphans),
|
||||
"form_undetermined": len(undetermined),
|
||||
"basis_found": basis_found,
|
||||
"basis_missing": len(basis_missing),
|
||||
"basis_grouped": basis_grouped,
|
||||
"form_undetermined": len(report["undetermined"]),
|
||||
"basis_found": report["basis_found"],
|
||||
"basis_missing": len(report["basis_missing"]),
|
||||
"basis_grouped": report["basis_grouped"],
|
||||
# 줄의 구실 — 공종(`work_item`) · 묶는 마디(`group`) · 총칙(`general_provision`) ·
|
||||
# 표도 아래도 없는 잎(`empty`). 화면·조합은 공종 축만 보면 된다.
|
||||
"axis_roles": role_counts,
|
||||
"keys_newly_issued": len(newly_issued),
|
||||
"variant_keys_newly_issued": len(newly_variants),
|
||||
"variant_keys": sum(len(n.get("variants") or []) for n in nodes),
|
||||
"toc_duplicate_rows": len(toc_duplicates),
|
||||
"toc_corrections": len(toc_corrections),
|
||||
},
|
||||
# 목차 오기를 본문 절 번호로 바로잡은 줄 — 목차에 적힌 번호(`toc_number`)도 남김.
|
||||
"toc_corrections": toc_corrections,
|
||||
# ⚠ 품셈 목차가 **같은 번호를 두 번 적은 자리**. 목차 번호를 열쇠로 쓰면 이 줄들이
|
||||
# 서로 덮는다 — 열쇠를 따로 두는 까닭의 실물이다.
|
||||
"toc_duplicate_rows": toc_duplicates,
|
||||
"orphan_tables": orphans,
|
||||
"work_items": nodes,
|
||||
},
|
||||
undetermined,
|
||||
basis_missing,
|
||||
report["undetermined"],
|
||||
report["basis_missing"],
|
||||
(registry, newly_issued),
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
master, undetermined, basis_missing = build()
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Const import (
|
||||
CONST_KEY_REGISTRY,
|
||||
FILE_TAG,
|
||||
build_const,
|
||||
)
|
||||
|
||||
write_outputs(build(), KEY_REGISTRY, "")
|
||||
write_outputs(build_const(), CONST_KEY_REGISTRY, f"{FILE_TAG}_")
|
||||
|
||||
|
||||
def write_outputs(result: tuple, registry_path: Path, tag: str) -> None:
|
||||
"""산출 넷(마스터·미판정·밑수 미확보·판 지문) + 장부. `tag` — 건설은 `const_`(산림 파일과 안 부딪힘)."""
|
||||
master, undetermined, basis_missing, (registry, newly_issued) = result
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import save_registry
|
||||
|
||||
save_registry(registry_path, registry)
|
||||
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 = OUT_DIR / f"{tag}work_item_master_{date}.json"
|
||||
undet_path = OUT_DIR / f"{tag}form_undetermined_{date}.json"
|
||||
basis_path = OUT_DIR / f"{tag}basis_missing_{date}.json"
|
||||
|
||||
master_path.write_text(json.dumps(master, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
undet_path.write_text(
|
||||
@@ -818,11 +451,12 @@ def main() -> None:
|
||||
for p in (master_path, undet_path, basis_path)
|
||||
],
|
||||
}
|
||||
(OUT_DIR / "_manifest.json").write_text(
|
||||
(OUT_DIR / (f"_manifest_{tag.rstrip('_')}.json" if tag else "_manifest.json")).write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=1), encoding="utf-8"
|
||||
)
|
||||
|
||||
s = master["stats"]
|
||||
print(f"── {master['dataset_id']}")
|
||||
print(f"목차 계층 {s['toc_nodes']}")
|
||||
print(
|
||||
f"표 귀속 {s['tables_attached']} / {s['tables_total']} (미귀속 {s['tables_orphan']})"
|
||||
@@ -830,6 +464,18 @@ def main() -> None:
|
||||
print(f"형태 미판정 {s['form_undetermined']}")
|
||||
print(f"밑수 확보 {s['basis_found']} (묶음 기준 {s['basis_grouped']})")
|
||||
print(f"밑수 미확보 {s['basis_missing']} → {basis_path.name}")
|
||||
roles = s["axis_roles"]
|
||||
print(
|
||||
"줄 구실 공종 {} · 묶는 마디 {} · 총칙 {} · 빈 잎 {}".format(
|
||||
roles.get("work_item", 0),
|
||||
roles.get("group", 0),
|
||||
roles.get("general_provision", 0),
|
||||
roles.get("empty", 0),
|
||||
)
|
||||
)
|
||||
print(f"열쇠 새로 발급 {len(newly_issued)} (장부 {registry_path.name})")
|
||||
if s["toc_duplicate_rows"]:
|
||||
print(f"목차 번호 겹침 {s['toc_duplicate_rows']} (열쇠는 안 겹침)")
|
||||
print(f"산출 {master_path.relative_to(ROOT)}")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
"""건설공사 표준품셈 2,192표 → **건설 공종 축**(`CW-00001`) (2026-09-17 브레인 8-2).
|
||||
|
||||
산림(`B08_Quantity_Build_WorkItemMaster.py`)과 같은 칸·같은 판정을 쓰고, 다른 셋만 여기 둠.
|
||||
|
||||
① 목차표가 없음 — 합본 원문(`2026년_건설공사_표준품셈.md`) 머리 「목 차」 글줄에서 계층을 읽음.
|
||||
한 장이 한 줄로 이어져 있고 **쪽 번호가 다음 번호에 붙어 있음**(「31-1-1 목적」 = 3쪽 + 1-1-1)
|
||||
⇒ 장 번호로 시작하는 자리까지 앞 숫자를 한 자씩 떼며 가름.
|
||||
② 부문 다섯이 장 번호를 **각자 1부터** 셈 — 공통 1장 적용기준 ≠ 토목 1장 도로포장.
|
||||
코드에 부문을 넣음(`CP-02-01-03` = 토목 1-3) · 뿌리 마디가 부문.
|
||||
③ 표를 절에 붙이는 번호는 **본문 절 제목**에서 읽음(목차 번호를 믿지 않음 · 브레인 2026-09-17).
|
||||
원천 `section` 칸은 다른 절 인용(「8-2-3 굴착기’를 적용하여」)·쪽 번호(「85-3 말뚝」)가 섞여
|
||||
못 씀 → 표가 놓인 원문 줄에서 위로 거슬러 **번호 뒤 글이 목차 이름과 같은** 제목을 찾음.
|
||||
장 파일 경계가 쪽 기준이라(원문 머리 경고) 앞뒤 장 제목도 받고, 파일 첫머리 표는 앞 장 파일 꼬리까지 봄.
|
||||
⚠ 못 붙인 표는 조용히 버리지 않고 `orphan_tables` 목록.
|
||||
⚠ 총칙·묶는 마디 덜어내기 안 함(가름만) — 총칙 뿌리는 `axis_policy.json` 건설 축(데스크탑 서브 잣대) · 미확정 둘은 안 넣음.
|
||||
갈래 = 산림과 같은 B09 표 읽기로 가름(못 가른 공종은 빈칸) · 갈래 불변 열쇠는 장부(`CW-00001#01`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SOURCE = ROOT / "resources" / "data_cost_input_value" / "pum_const_2026.json"
|
||||
#: 건설 열쇠 장부 — 산림 장부(`work_item_keys.json`)와 일련번호를 안 섞음
|
||||
CONST_KEY_REGISTRY = ROOT / "resources" / "data_work_item_master" / "work_item_keys_const.json"
|
||||
KEY_PREFIX = "CW" # Construction Work item
|
||||
BOOK_NAME = "2026년_건설공사_표준품셈.md"
|
||||
CODE_PREFIX = "CP" # Construction Pumsem — 목차 코드
|
||||
DATASET_ID = "work_item_master_const"
|
||||
FILE_TAG = "const" # 산출 파일 이름 **앞**에 붙임 — ⚠ 뒤에 붙이면 `work_item_master_*` 로 산림을 고르는 쪽(B08 밑수 대조 · B09 근거표)이 건설을 집음
|
||||
DIVISIONS = ("공통부문", "토목부문", "건축부문", "기계설비부문", "유지관리부문")
|
||||
|
||||
_LEADER = re.compile(r"·{2,}")
|
||||
_TOC_PAGE_HEAD = re.compile(r"\d*목차") # 목차 쪽 머리(「19목차」)가 줄 가운데 끼어 있음
|
||||
_NUMBER = re.compile(r"\d+(?:-\d+)+")
|
||||
#: 이름 바로 뒤에 이 글자가 오면 **인용**이지 제목이 아님 — 「3-2-4 터파기(기계)’를 참고하여」 ·
|
||||
#: 「6-2-2 현장가공, 6-2-3 현장조립’을」 · 「1-4-6 작업제한/작업시간제한’」 · 「[주] ④’」.
|
||||
#: ⚠ 제목 뒤에 본문이 바로 붙는 꼴(「8-1-4 시공능력 산정 기본식Q=…」)이 많아 허용 목록으로는 못 가름.
|
||||
_QUOTED_NEXT = "’‘”“'\"/,[]~」』"
|
||||
|
||||
|
||||
def _squeeze(text: Any) -> str:
|
||||
return "".join(str(text or "").split())
|
||||
|
||||
|
||||
def toc_text(lines: list[str]) -> dict[str, str]:
|
||||
"""부문 → 그 부문 목차 글(장 줄을 이어 붙임). 목차 뒤 첫 딴 줄에서 멈춤."""
|
||||
out: dict[str, str] = {}
|
||||
division = None
|
||||
started = False
|
||||
for line in lines:
|
||||
text = line.strip()
|
||||
if not started:
|
||||
started = _squeeze(text) == "목차"
|
||||
continue
|
||||
if not text:
|
||||
continue
|
||||
if text in DIVISIONS:
|
||||
division = text
|
||||
out[division] = ""
|
||||
elif division and re.match(r"제\d+장", text):
|
||||
out[division] += text
|
||||
elif out:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _number_at(text: str, chapter: str, *, after_name: bool) -> int | None:
|
||||
"""`text` 안에서 장 번호로 시작하는 절 번호 자리. 앞은 쪽 번호(숫자)뿐이어야 함.
|
||||
|
||||
`after_name` — 장 머리(「적용기준3」 + 1-1)처럼 앞에 이름 + 쪽 번호가 올 때.
|
||||
"""
|
||||
for index in range(len(text)):
|
||||
head = text[:index]
|
||||
if after_name:
|
||||
if not re.fullmatch(r".*\D\d+", head):
|
||||
continue
|
||||
elif head and not head.isdigit():
|
||||
break
|
||||
if re.match(rf"{chapter}(?:-\d+)+", text[index:]):
|
||||
return index
|
||||
return None
|
||||
|
||||
|
||||
def toc_entries(text: str) -> list[tuple[str, str, str]]:
|
||||
"""부문 목차 글 → `(장, 번호, 이름)` 차례. 장 줄은 번호 자리에 장 번호."""
|
||||
out: list[tuple[str, str, str]] = []
|
||||
chapter = None
|
||||
for segment in _LEADER.split(_TOC_PAGE_HEAD.sub("", text)):
|
||||
segment = segment.strip()
|
||||
head = re.search(r"제(\d+)장", segment)
|
||||
if head:
|
||||
chapter = head.group(1)
|
||||
rest = segment[head.end() :]
|
||||
at = _number_at(rest, chapter, after_name=True)
|
||||
if at is None:
|
||||
raise ValueError(f"목차 장 머리를 못 가름: {segment[:40]}")
|
||||
out.append((chapter, chapter, re.sub(r"\d+$", "", rest[:at]).strip()))
|
||||
segment = rest[at:]
|
||||
if chapter is None or not segment or segment.isdigit():
|
||||
continue
|
||||
at = _number_at(segment, chapter, after_name=False)
|
||||
if at is None:
|
||||
raise ValueError(f"목차 줄을 못 가름: {segment[:40]}")
|
||||
number = re.match(rf"{chapter}(?:-\d+)+", segment[at:]).group(0)
|
||||
out.append((chapter, number, segment[at + len(number) :].strip()))
|
||||
return out
|
||||
|
||||
|
||||
def _code(root: str, number: str) -> str:
|
||||
return f"{root}-" + "-".join(part.zfill(2) for part in number.split("-"))
|
||||
|
||||
|
||||
def _node(
|
||||
code: str, number: str, name: str, level: int, parent: str | None, order: int, division: str
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"work_item_code": code,
|
||||
"number": number,
|
||||
"name": name,
|
||||
"level": level,
|
||||
"parent_code": parent,
|
||||
"sort_order": order,
|
||||
"tables": [],
|
||||
"division": division,
|
||||
}
|
||||
|
||||
|
||||
def toc_nodes(lines: list[str]) -> list[dict[str, Any]]:
|
||||
"""목차 → 산림과 같은 꼴 마디(부문 뿌리 + 장 + 절 + 항). ⚠ 번호가 겹치거나 윗줄이 없으면 멈춤."""
|
||||
nodes: list[dict[str, Any]] = []
|
||||
order = 0
|
||||
for division, text in toc_text(lines).items():
|
||||
root = f"{CODE_PREFIX}-{DIVISIONS.index(division) + 1:02d}"
|
||||
order += 256
|
||||
nodes.append(_node(root, division, division, 1, None, order, division))
|
||||
for _, number, name in toc_entries(text):
|
||||
parts = number.split("-")
|
||||
parent = _code(root, "-".join(parts[:-1])) if len(parts) > 1 else root
|
||||
order += 256
|
||||
nodes.append(
|
||||
_node(_code(root, number), number, name, len(parts) + 1, parent, order, division)
|
||||
)
|
||||
codes = [n["work_item_code"] for n in nodes]
|
||||
if len(set(codes)) != len(codes):
|
||||
raise ValueError("건설 목차 번호가 겹침 — 본문 대조 필요")
|
||||
missing = {n["parent_code"] for n in nodes if n["parent_code"]} - set(codes)
|
||||
if missing:
|
||||
raise ValueError(f"건설 목차 윗줄이 없음: {sorted(missing)[:5]}")
|
||||
return nodes
|
||||
|
||||
|
||||
def file_place(source_file: str) -> tuple[str, int]:
|
||||
"""`…/02_토목부문/제5장_강구조공사.md` → `("토목부문", 5)`."""
|
||||
parts = Path(source_file).parts
|
||||
return parts[-2].split("_", 1)[1], int(re.match(r"제(\d+)장", parts[-1]).group(1))
|
||||
|
||||
|
||||
def titles_in(text: str, division: str, chapters: set[str], names: dict) -> list[str]:
|
||||
"""한 줄 안의 **본문 절 제목** 번호들(차례대로). 번호 뒤 글이 목차 이름과 같아야 제목."""
|
||||
found = []
|
||||
for match in _NUMBER.finditer(text):
|
||||
raw = match.group(0)
|
||||
for cut in range(len(raw.split("-")[0])): # 붙은 쪽 번호를 한 자씩 뗌(「85-3」 → 5-3)
|
||||
number = raw[cut:]
|
||||
name = names.get((division, number))
|
||||
if number.split("-")[0] not in chapters or name is None:
|
||||
continue
|
||||
tail, key = _squeeze(text[match.end() :]), _squeeze(name)
|
||||
after = tail[len(key) : len(key) + 1]
|
||||
if tail.startswith(key) and not (after and after in _QUOTED_NEXT):
|
||||
found.append(number)
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
class SectionFinder:
|
||||
"""표 → 본문 절 번호. 파일마다 「제목이 있는 줄 · 그 번호」 를 한 번만 셈."""
|
||||
|
||||
def __init__(self, nodes: list[dict[str, Any]], source_files: list[str]):
|
||||
self.names = {(n["division"], n["number"]): n["name"] for n in nodes if n["level"] > 1}
|
||||
self.marks: dict[str, tuple[list[int], list[str]]] = {}
|
||||
ordered = sorted(set(source_files), key=file_place)
|
||||
self.previous = {
|
||||
after: before
|
||||
for before, after in zip(ordered, ordered[1:])
|
||||
if file_place(before)[0] == file_place(after)[0]
|
||||
}
|
||||
|
||||
def _marks(self, source_file: str) -> tuple[list[int], list[str]]:
|
||||
if source_file not in self.marks:
|
||||
division, chapter = file_place(source_file)
|
||||
chapters = {str(chapter - 1), str(chapter), str(chapter + 1)}
|
||||
lines = (ROOT / source_file).read_text(encoding="utf-8").splitlines()
|
||||
at, numbers = [], []
|
||||
for index, line in enumerate(lines):
|
||||
found = titles_in(line, division, chapters, self.names)
|
||||
if found:
|
||||
at.append(index)
|
||||
numbers.append(found[-1])
|
||||
self.marks[source_file] = (at, numbers)
|
||||
return self.marks[source_file]
|
||||
|
||||
def number(self, table: dict[str, Any]) -> str | None:
|
||||
at, numbers = self._marks(table["source_file"])
|
||||
index = bisect.bisect_left(at, int(table.get("line") or 0) - 1) # 표 줄보다 위
|
||||
if index:
|
||||
return numbers[index - 1]
|
||||
previous = self.previous.get(table["source_file"])
|
||||
if previous: # 파일 첫머리 표 — 제목은 앞 장 파일 꼬리에 있음
|
||||
_, numbers = self._marks(previous)
|
||||
return numbers[-1] if numbers else None
|
||||
return None
|
||||
|
||||
|
||||
def general_roots() -> tuple[str, ...]:
|
||||
"""총칙 뿌리 — `axis_policy.json` 건설 축의 장 파일 이름을 목차 코드로(「01_공통부문/제1장_적용기준.md」 → `CP-01-01`).
|
||||
|
||||
⚠ 미확정(`general_provision_pending` · 제8장 건설기계 · 유지관리 제1장)은 안 넣음 — 판정이 오면 자료만 고치면 됨.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import general_provision_roots
|
||||
|
||||
roots = []
|
||||
for chapter_file in general_provision_roots("const"):
|
||||
division, chapter = file_place(chapter_file)
|
||||
roots.append(_code(f"{CODE_PREFIX}-{DIVISIONS.index(division) + 1:02d}", str(chapter)))
|
||||
return tuple(roots)
|
||||
|
||||
|
||||
def book_lines(data: dict[str, Any]) -> list[str]:
|
||||
folder = (ROOT / str(data["sources"][0]["path"])).parents[1]
|
||||
return (folder / BOOK_NAME).read_text(encoding="utf-8").splitlines()
|
||||
|
||||
|
||||
def build_const() -> tuple:
|
||||
"""건설 공종 축 한 벌 — 산림 `build()` 와 같은 꼴(마스터 · 미판정 · 밑수 미확보 · (장부, 새 열쇠))."""
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import finish
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Parents import apply_parent_modes
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Table import new_report, norm, table_entry
|
||||
|
||||
data = json.loads(SOURCE.read_text(encoding="utf-8"))
|
||||
tables = data["variables"]["pum"]["tables"]
|
||||
nodes = toc_nodes(book_lines(data))
|
||||
by_place = {(n["division"], n["number"]): n for n in nodes if n["level"] > 1}
|
||||
finder = SectionFinder(nodes, [t["source_file"] for t in tables])
|
||||
report = new_report()
|
||||
attached = 0
|
||||
orphans: list[dict[str, Any]] = []
|
||||
lines_of: dict[str, list[str]] = {}
|
||||
for table in tables:
|
||||
division, _ = file_place(table["source_file"])
|
||||
number = finder.number(table)
|
||||
node = by_place.get((division, number))
|
||||
if table["source_file"] not in lines_of:
|
||||
lines_of[table["source_file"]] = (
|
||||
(ROOT / table["source_file"]).read_text(encoding="utf-8").splitlines()
|
||||
)
|
||||
# 형태 판정의 「1장 = 적용기준」 은 공통부문 1장만 — 다른 부문 1장은 도로포장·철골·배관 따위
|
||||
chapter = "1" if division == DIVISIONS[0] and number and number.startswith("1-") else None
|
||||
section = f"{number} {node['name']}" if node else norm(table.get("section"))
|
||||
entry = table_entry(
|
||||
table,
|
||||
section,
|
||||
number,
|
||||
chapter,
|
||||
lines_of[table["source_file"]],
|
||||
node and node["name"],
|
||||
report,
|
||||
)
|
||||
if node is None:
|
||||
orphans.append(
|
||||
{
|
||||
"pum_table_id": table["table_id"],
|
||||
"section": norm(table.get("section")),
|
||||
"source_file": table["source_file"],
|
||||
}
|
||||
)
|
||||
continue
|
||||
node["tables"].append(entry)
|
||||
attached += 1
|
||||
apply_parent_modes(nodes, {}) # 합산형은 사람이 적은 것만 — 건설은 아직 없음(전부 choose_one)
|
||||
# 갈래 — 산림과 **같은 표 읽기 한 벌**(B09 `build_resource_axis`)로 가름 · 못 가른 공종은 빈칸.
|
||||
# 갈래 불변 열쇠(`CW-00001#01`)는 `finish` 가 장부로 줌(데스크탑 서브 · 2026-09-17 브레인 ④).
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Variants import attach_variant_keys
|
||||
|
||||
attach_variant_keys(nodes, data["effective_date"])
|
||||
return finish(
|
||||
data,
|
||||
source=SOURCE,
|
||||
dataset_id=DATASET_ID,
|
||||
nodes=nodes,
|
||||
tables_total=len(tables),
|
||||
attached=attached,
|
||||
orphans=orphans,
|
||||
report=report,
|
||||
registry_path=CONST_KEY_REGISTRY,
|
||||
toc_corrections=[],
|
||||
key_prefix=KEY_PREFIX,
|
||||
general_roots=general_roots(),
|
||||
policy={
|
||||
"divisions": list(DIVISIONS),
|
||||
"toc_source": BOOK_NAME + " 머리 「목 차」",
|
||||
"section_from_body": True,
|
||||
"variant_keys_attached": True, # 산림과 같은 표 읽기 — 못 가른 공종은 빈칸
|
||||
"general_provision_roots": list(general_roots()),
|
||||
},
|
||||
)
|
||||
@@ -123,7 +123,7 @@ FORM_JUDGMENTS: dict[str, tuple[str, str, str]] = {
|
||||
# ── 제13장 구조물 ───────────────────────────────────────────────
|
||||
"F0400": ("13-2-3", "reference", "큰돌 직경별 뒷길이 기준(㎝)"),
|
||||
"F0406": (
|
||||
"13-3",
|
||||
"13-4-1", # 2026-09-17 절 바로잡음 — [주]④ 인용을 13-3 제목으로 읽었던 것(표는 13-4-1 메쌓기 아래)
|
||||
"reference",
|
||||
"〈돌쌓기의 개수 및 중량의 표준〉 — [주] 인력 돌쌓기·돌붙임 기준",
|
||||
),
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
"""공종 마스터의 **불변 열쇠**(`FW-00001`) — 장부 읽기·발급·칸 얹기 (2026-09-17 브레인 ①).
|
||||
|
||||
왜 필요한가
|
||||
지금 공종의 열쇠는 품셈 목차 번호(`FP-09-03-02`)다. 품셈이 개정돼 절이 지워지거나 번호가
|
||||
밀리면 **일위대가·조합이 조용히 딴 줄을 가리킨다.** 그래서 번호와 무관한 일련번호를 얹고,
|
||||
목차 번호·이름·판은 **칸으로 내린다.**
|
||||
|
||||
한 번 준 열쇠는 영원히 그 공종 것
|
||||
그 약속을 지키는 자리가 **장부**(`resources/data_work_item_master/work_item_keys.json`)다.
|
||||
판(고시 번호)마다 「그 판의 목차 코드 → 열쇠」를 적어 둔다. 장부에 있으면 그 열쇠를 다시 쓰고,
|
||||
없으면 **새 열쇠를 내고 `newly_issued` 로 뽑아 사람이 보게 한다.**
|
||||
|
||||
⚠ 짐작으로 잇지 않는다
|
||||
새 판에서 이름이 비슷하다고 옛 열쇠를 물려주지 않는다. 그건 **틀리면 금액이 조용히 바뀌는**
|
||||
종류의 추측이다. 잇는 것은 장부에 손으로 적을 때만 — 그때까지는 새 열쇠 + 목록이다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
KEY_PREFIX = (
|
||||
"FW" # Forest Work item — 산림 공종 열쇠 · 건설은 장부를 따로 두고 `CW`(장부 `key_prefix`)
|
||||
)
|
||||
KEY_DIGITS = 5
|
||||
PATH_SEP = " › "
|
||||
|
||||
#: 줄 구실 잣대는 **자료에 둔다** — 총칙 범위는 기관이 정하는 것이라 코드에 박으면 관리자가 못 고친다.
|
||||
AXIS_POLICY = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "resources"
|
||||
/ "data_work_item_master"
|
||||
/ "axis_policy.json"
|
||||
)
|
||||
|
||||
|
||||
def load_policy(axis: str, path: Path = AXIS_POLICY) -> dict[str, Any]:
|
||||
"""`axis_policy.json` 의 한 축(`forest`·`const`). 없는 축을 부르면 멈춘다."""
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
try:
|
||||
return data["axes"][axis]
|
||||
except KeyError:
|
||||
raise ValueError(f"공종 축 잣대에 `{axis}` 가 없다 — {path}") from None
|
||||
|
||||
|
||||
def general_provision_roots(axis: str = "forest", path: Path = AXIS_POLICY) -> tuple[str, ...]:
|
||||
"""총칙 뿌리 — 값표는 붙어 있으나 공종이 아닌 장. ⚠ 지우지 않고 구실로만 가른다."""
|
||||
return tuple(load_policy(axis, path).get("general_provision_roots") or ())
|
||||
|
||||
|
||||
#: 원문 경로에 박힌 고시 번호 — 「(산림청고시 제2025-82호)」.
|
||||
_EDITION_RE = re.compile(r"([가-힣]*고시\s*제\s*\d{4}-\d+\s*호)")
|
||||
|
||||
|
||||
def toc_edition(sources: list[dict[str, Any]] | None, fallback: str) -> str:
|
||||
"""원문 경로에서 고시 번호를 읽는다. 못 읽으면 `fallback`(시행일).
|
||||
|
||||
⚠ 번호를 코드에 박지 않는다 — 판이 바뀌면 원문 경로가 먼저 바뀐다.
|
||||
"""
|
||||
for entry in sources or []:
|
||||
if m := _EDITION_RE.search(str(entry.get("path") or "")):
|
||||
return re.sub(r"\s+", "", m.group(1))
|
||||
return fallback
|
||||
|
||||
|
||||
def empty_registry(prefix: str = KEY_PREFIX) -> dict[str, Any]:
|
||||
"""빈 장부. 첫 판을 지을 때 이 꼴로 시작한다."""
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "work_item_keys",
|
||||
"note": (
|
||||
"공종의 **불변 열쇠** 장부. 판이 바뀌어도 이 열쇠는 그 공종 것이다. "
|
||||
"판마다 「그 판의 목차 코드 → 열쇠」를 적는다."
|
||||
),
|
||||
"policy": {
|
||||
"key_is_permanent": True,
|
||||
"no_guessed_relink": True,
|
||||
"note": (
|
||||
"새 판의 목차 코드가 장부에 없으면 **새 열쇠**를 낸다. 이름이 비슷하다고 옛 열쇠를 "
|
||||
"물려주지 않는다 — 잇는 것은 이 파일에 손으로 적을 때만."
|
||||
),
|
||||
},
|
||||
"key_prefix": prefix,
|
||||
"key_digits": KEY_DIGITS,
|
||||
"next_serial": 1,
|
||||
"editions": {},
|
||||
"keys": {},
|
||||
}
|
||||
|
||||
|
||||
def load_registry(path: Path, prefix: str = KEY_PREFIX) -> dict[str, Any]:
|
||||
"""장부를 읽는다. 없으면 빈 장부."""
|
||||
if not path.exists():
|
||||
return empty_registry(prefix)
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
for field in ("next_serial", "editions", "keys"):
|
||||
if field not in data:
|
||||
raise ValueError(f"열쇠 장부에 `{field}` 가 없다 — {path}")
|
||||
return data
|
||||
|
||||
|
||||
def save_registry(path: Path, registry: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(registry, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def format_key(serial: int, prefix: str = KEY_PREFIX) -> str:
|
||||
return f"{prefix}-{serial:0{KEY_DIGITS}d}"
|
||||
|
||||
|
||||
def toc_slot(code: str, ordinal: int) -> str:
|
||||
"""장부가 쓰는 자리 이름. 첫 줄은 코드 그대로, 겹친 둘째부터 `#2` 를 붙인다.
|
||||
|
||||
⚠ 품셈 목차가 **같은 번호를 두 번 적은 자리**가 있다(12-17-2 · 12-24-1). 코드만으로는
|
||||
줄이 안 갈린다 — 목차 번호가 열쇠로 못 쓰이는 까닭 그 자체다.
|
||||
"""
|
||||
return code if ordinal == 1 else f"{code}#{ordinal}"
|
||||
|
||||
|
||||
def assign_keys(
|
||||
nodes: list[dict[str, Any]],
|
||||
registry: dict[str, Any],
|
||||
*,
|
||||
edition: str,
|
||||
issued_on: str | None = None,
|
||||
) -> tuple[list[dict[str, str]], list[dict[str, Any]]]:
|
||||
"""각 줄에 `work_item_key` 를 박고 `(새로 낸 것, 겹친 목차 번호)` 를 돌려준다.
|
||||
|
||||
차례는 목차 차례(`sort_order`)다 — 같은 입력이면 늘 같은 열쇠가 나온다.
|
||||
⚠ 줄마다 제 열쇠다. 목차 번호가 겹쳐도 열쇠는 **안 겹친다.**
|
||||
"""
|
||||
book = registry["editions"].setdefault(edition, {})
|
||||
issued_on = issued_on or date.today().isoformat()
|
||||
newly: list[dict[str, str]] = []
|
||||
duplicates: list[dict[str, Any]] = []
|
||||
seen: dict[str, int] = {}
|
||||
for node in sorted(nodes, key=lambda n: n["sort_order"]):
|
||||
code = node["work_item_code"]
|
||||
ordinal = seen[code] = seen.get(code, 0) + 1
|
||||
slot = toc_slot(code, ordinal)
|
||||
if ordinal > 1:
|
||||
duplicates.append(
|
||||
{"toc_code": code, "toc_number": node["number"], "name": node["name"], "slot": slot}
|
||||
)
|
||||
key = book.get(slot)
|
||||
if key is None:
|
||||
key = format_key(registry["next_serial"], registry.get("key_prefix", KEY_PREFIX))
|
||||
registry["next_serial"] += 1
|
||||
book[slot] = key
|
||||
registry["keys"][key] = {
|
||||
"issued_edition": edition,
|
||||
"issued_on": issued_on,
|
||||
"first_toc_code": code,
|
||||
"first_toc_number": node["number"],
|
||||
"first_name": node["name"],
|
||||
}
|
||||
newly.append({"work_item_key": key, "toc_slot": slot, "name": node["name"]})
|
||||
node["work_item_key"] = key
|
||||
return newly, duplicates
|
||||
|
||||
|
||||
#: 갈래 열쇠 자릿수 — `FW-00105#01` 의 뒤 두 자리.
|
||||
VARIANT_DIGITS = 2
|
||||
#: 원문이 섞어 쓰는 물결표 — 맞댈 때 하나로 모은다.
|
||||
_TILDES = {"~": "∼", "〜": "∼", "~": "∼"}
|
||||
|
||||
|
||||
def variant_match(text: str) -> str:
|
||||
"""갈래를 맞대는 꼴 — **공백을 다 떼고** 물결표를 하나로.
|
||||
|
||||
⚠ 왜 이렇게까지 지우나 — 원문이 「간 단」·「간 단」처럼 자간을 들쭉날쭉 쓰고, 이름 다듬기가
|
||||
앞으로 더 들어올 자리다. 맞대는 꼴이 공백에 걸리면 **다듬는 날 열쇠가 갈린다.**
|
||||
(산림 335 갈래로 재 보니 한 공종 안에서 이 꼴이 겹치는 자리는 없다 — 2026-09-17.)
|
||||
"""
|
||||
squeezed = "".join(str(text or "").split())
|
||||
return "".join(_TILDES.get(ch, ch) for ch in squeezed)
|
||||
|
||||
|
||||
_SYLLABLE = re.compile(r"^[가-힣]$")
|
||||
_PIECE_GAP = re.compile(r"( [·ㆍ・›] )")
|
||||
|
||||
|
||||
def tidy_spacing(text: str) -> str:
|
||||
"""**자간 공백** 다듬기 — 「측 량」→「측량」 · 「평 규 준 틀」→「평규준틀」 · 「굴 삭 기 (무한궤도)」→「굴삭기 (무한궤도)」.
|
||||
|
||||
한 글자 낱말뿐인 조각이거나, 한 글자 낱말이 **셋 이상** 이어질 때만 붙임 —
|
||||
「그 외 자재」·「호박돌 및 야면석」 같은 진짜 띄어쓰기는 그대로(2026-09-17 서브 훑기 175줄 · 브레인).
|
||||
⚠ 보일 이름만 — 열쇠는 번호라(공종 `FW-…` · 갈래 `#01`) 다듬어도 안 흔들림.
|
||||
"""
|
||||
# 가운뎃점·경로 표시 사이가 한 조각 — 「메 붙 임 · 깬 돌 · 뒷길이 25㎝」 의 「깬 돌」
|
||||
pieces = _PIECE_GAP.split(" ".join(str(text or "").split()))
|
||||
return "".join(piece if n % 2 else _tidy_piece(piece) for n, piece in enumerate(pieces))
|
||||
|
||||
|
||||
def _tidy_piece(text: str) -> str:
|
||||
tokens = text.split(" ")
|
||||
if len(tokens) >= 2 and all(_SYLLABLE.match(t) for t in tokens):
|
||||
return "".join(tokens)
|
||||
out: list[str] = []
|
||||
run: list[str] = []
|
||||
for token in [*tokens, ""]:
|
||||
if token and _SYLLABLE.match(token):
|
||||
run.append(token)
|
||||
continue
|
||||
out.extend(["".join(run)] if len(run) >= 3 else run)
|
||||
run = []
|
||||
if token:
|
||||
out.append(token)
|
||||
return " ".join(out)
|
||||
|
||||
|
||||
def variant_display(text: str) -> str:
|
||||
"""다듬은 갈래 이름 — 자간 공백을 붙이고 물결표를 하나로. 원문은 따로 남긴다."""
|
||||
return "".join(_TILDES.get(ch, ch) for ch in tidy_spacing(text))
|
||||
|
||||
|
||||
def format_variant(serial: int) -> str:
|
||||
return f"{serial:0{VARIANT_DIGITS}d}"
|
||||
|
||||
|
||||
def assign_variant_keys(
|
||||
nodes: list[dict[str, Any]], registry: dict[str, Any], *, issued_on: str | None = None
|
||||
) -> list[dict[str, str]]:
|
||||
"""갈래마다 **불변 열쇠**(`FW-00105#01`)를 주고 `variants` 칸을 얹는다.
|
||||
|
||||
공종 열쇠와 같은 병을 갈래가 그대로 앓고 있었다 — 갈래 글자가 열쇠에 들어가 있어
|
||||
**원문 자간이나 물결표가 손질되면 열쇠가 갈렸다.** 그래서 장부에 올린다(2026-09-17 브레인).
|
||||
|
||||
장부 자리는 **공종 열쇠 아래**다(목차 코드 아래가 아니다) — 목차 번호가 밀려도 안 흔들린다.
|
||||
⚠ 장부에 없는 갈래는 **새 번호**를 받는다. 비슷하다고 옛 번호를 물려주지 않는다.
|
||||
사라진 갈래의 번호는 **비워 둔 채 다시 쓰지 않는다** — 옛 일위대가가 가리키던 자리다.
|
||||
"""
|
||||
issued_on = issued_on or date.today().isoformat()
|
||||
newly: list[dict[str, str]] = []
|
||||
for node in sorted(nodes, key=lambda n: n["sort_order"]):
|
||||
raw = node.get("variant_keys") or []
|
||||
if not raw:
|
||||
node["variants"] = []
|
||||
continue
|
||||
# ⚠ 갈래가 하나도 없으면 장부에 빈 칸을 만들지 않는다 — 건설처럼 아직 갈래가 없는 벌에
|
||||
# 빈 `variants` 가 생기면 이름표가 「있는데 이름이 없는 표」로 잡는다.
|
||||
book = registry.setdefault("variants", {})
|
||||
entry = book.setdefault(node["work_item_key"], {"next_serial": 1, "slots": {}})
|
||||
variants = []
|
||||
for text in raw:
|
||||
slot = variant_match(text)
|
||||
serial = entry["slots"].get(slot)
|
||||
if serial is None:
|
||||
serial = format_variant(entry["next_serial"])
|
||||
entry["next_serial"] += 1
|
||||
entry["slots"][slot] = serial
|
||||
newly.append(
|
||||
{
|
||||
"variant_key": f"{node['work_item_key']}#{serial}",
|
||||
"name": text,
|
||||
"issued_on": issued_on,
|
||||
}
|
||||
)
|
||||
variants.append(
|
||||
{
|
||||
"variant_key": serial,
|
||||
"name": text, # 원문 그대로 — 손대지 않는다
|
||||
"name_clean": variant_display(text), # 다듬은 것
|
||||
}
|
||||
)
|
||||
node["variants"] = variants
|
||||
return newly
|
||||
|
||||
|
||||
def path_names(nodes: list[dict[str, Any]]) -> list[str]:
|
||||
"""`["토공 › 토사깍기 › 인력", …]` — `nodes` 차례에 맞춘 전체 경로 이름.
|
||||
|
||||
잎 이름이 겹쳐도(「인력」 3곳 · 「수확」 6곳) 경로로 갈린다.
|
||||
⚠ 줄마다 따로 짓는다 — 목차 번호가 겹친 줄이 있어 코드로 묶으면 한쪽이 덮인다.
|
||||
윗줄 찾기는 코드로 한다(겹친 코드는 다 잎이라 윗줄 자리에 안 선다).
|
||||
"""
|
||||
by_code = {n["work_item_code"]: n for n in nodes}
|
||||
cache: dict[str, str] = {}
|
||||
|
||||
def upward(code: str, seen: frozenset[str] = frozenset()) -> str:
|
||||
if code in cache:
|
||||
return cache[code]
|
||||
node = by_code[code]
|
||||
parent = node.get("parent_code")
|
||||
name = str(node.get("name") or "")
|
||||
if parent and parent in by_code and parent not in seen:
|
||||
name = upward(parent, seen | {code}) + PATH_SEP + name
|
||||
cache[code] = name
|
||||
return name
|
||||
|
||||
out: list[str] = []
|
||||
for node in nodes:
|
||||
parent = node.get("parent_code")
|
||||
name = str(node.get("name") or "")
|
||||
if parent and parent in by_code:
|
||||
name = upward(parent) + PATH_SEP + name
|
||||
out.append(name)
|
||||
return out
|
||||
|
||||
|
||||
def axis_role(
|
||||
node: dict[str, Any], *, has_children: bool, roots: tuple[str, ...] | None = None
|
||||
) -> str:
|
||||
"""이 줄이 공종인가 문서 구역인가. 차례가 뜻을 가진다(총칙 먼저).
|
||||
|
||||
`work_item` 품셈 표가 붙은 줄 — 값이 있는 자리
|
||||
`group` 표는 없고 아래를 묶는 마디 — `parent_mode` 가 계산 규칙을 지닌다(지우면 안 된다)
|
||||
`general_provision` 총칙 — 문서 구역이나 **값표가 붙어 있어 B09 가 읽는다**
|
||||
`empty` 표도 아래도 없는 잎 — 원문에 표가 안 붙은 자리
|
||||
|
||||
⚠ **어느 구실이든 지우지 않는다.** 가름은 화면·조합이 무엇을 볼지 고르는 것뿐이다.
|
||||
"""
|
||||
if roots is None:
|
||||
roots = general_provision_roots()
|
||||
code = node["work_item_code"]
|
||||
if any(code == root or code.startswith(root + "-") for root in roots):
|
||||
return "general_provision"
|
||||
if node.get("tables"):
|
||||
return "work_item"
|
||||
return "group" if has_children else "empty"
|
||||
|
||||
|
||||
def decorate(
|
||||
nodes: list[dict[str, Any]], *, edition: str, roots: tuple[str, ...] | None = None
|
||||
) -> None:
|
||||
"""각 줄에 목차 칸·경로 이름·구실을 얹는다. 열쇠는 `assign_keys` 가 이미 박았다.
|
||||
|
||||
기존 칸은 손대지 않는다 — 금액을 낳는 값은 그대로다.
|
||||
"""
|
||||
if roots is None:
|
||||
roots = general_provision_roots()
|
||||
paths = path_names(nodes)
|
||||
has_kid = {n.get("parent_code") for n in nodes}
|
||||
for node, path in zip(nodes, paths):
|
||||
code = node["work_item_code"]
|
||||
node["toc_code"] = code
|
||||
node["toc_number"] = node["number"]
|
||||
node["toc_edition"] = edition
|
||||
node["path_name"] = path
|
||||
node["axis_role"] = axis_role(node, has_children=code in has_kid, roots=roots)
|
||||
@@ -42,7 +42,9 @@ SUM_STEPS: dict[str, dict[str, Any]] = {
|
||||
}
|
||||
|
||||
|
||||
def apply_parent_modes(nodes: list[dict[str, Any]]) -> None:
|
||||
def apply_parent_modes(
|
||||
nodes: list[dict[str, Any]], declared_steps: dict[str, dict[str, Any]] = SUM_STEPS
|
||||
) -> None:
|
||||
"""부모 공종에 `parent_mode`(합산형이면 `steps`·`steps_basis` 까지)를 단다 — 자리에서.
|
||||
|
||||
⚠ 선언한 잎이 그 부모의 자식이 아니면 멈춘다 — 판이 바뀌어 절 번호가 옮겨 간 자리다.
|
||||
@@ -55,7 +57,7 @@ def apply_parent_modes(nodes: list[dict[str, Any]]) -> None:
|
||||
code = node["work_item_code"]
|
||||
if code not in children:
|
||||
continue
|
||||
declared = SUM_STEPS.get(code)
|
||||
declared = declared_steps.get(code)
|
||||
if declared is None:
|
||||
node["parent_mode"] = PARENT_CHOOSE_ONE
|
||||
continue
|
||||
@@ -65,6 +67,6 @@ def apply_parent_modes(nodes: list[dict[str, Any]]) -> None:
|
||||
node["parent_mode"] = PARENT_SUM_STEPS
|
||||
node["steps"] = [{"code": step, "weight": weight} for step, weight in declared["steps"]]
|
||||
node["steps_basis"] = declared["basis"]
|
||||
missing = [code for code in SUM_STEPS if code not in children]
|
||||
missing = [code for code in declared_steps if code not in children]
|
||||
if missing:
|
||||
raise ValueError(f"합산형으로 적은 부모가 마스터에 없습니다: {missing}")
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
"""공종 마스터 — **표 한 장 읽기**: 형태·밑수·위험 깃발 (2026-09-17 뽑는 스크립트가 700줄을 넘어 떼어냄).
|
||||
|
||||
뽑는 스크립트(`B08_Quantity_Build_WorkItemMaster.py`)는 목차·절 붙이기·열쇠를 맡고, 표 한 장이 어떤
|
||||
표인지(`pum_form`)·밑수·깃발은 **여기 한 곳**에서 가름 — 산림·건설 뽑기가 같은 판정을 씀(두 벌 금지).
|
||||
옮기기만 했고 판정은 한 글자도 안 바꿈 — 옛 이름은 뽑는 스크립트가 그대로 다시 내보냄.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Forms import judged_form
|
||||
|
||||
# ── 형태 판정 ────────────────────────────────────────────────────────────
|
||||
# 헤더·비고 문자열에서 찾는 표지. 앞의 것이 먼저 걸린다(생산량형이 더 좁은 표현이라 우선).
|
||||
PRODUCTIVITY_MARKS = (
|
||||
"㎥/hr",
|
||||
"m3/hr",
|
||||
"㎡/hr",
|
||||
"본/hr",
|
||||
"/1인/1일",
|
||||
"/인/1일",
|
||||
"인/1일",
|
||||
"작업능력",
|
||||
"ha당 평균 작업량",
|
||||
"ha당 집재재적",
|
||||
)
|
||||
REQUIREMENT_MARKS = (
|
||||
"소요인력",
|
||||
"소요량",
|
||||
"당 주입량",
|
||||
"단위수량",
|
||||
"수 량",
|
||||
"수량",
|
||||
"인/km",
|
||||
"인/ha",
|
||||
"㏊당",
|
||||
"ha당",
|
||||
"당 소요",
|
||||
)
|
||||
# 값이 공종 품이 아니라 계산식 파라미터인 표. 공종으로 세우지 않는다.
|
||||
COEFFICIENT_MARKS = ("손료계수", "시간당손료계수", "기계손료")
|
||||
# 기계 시공능력 공식(Q = 3600·qo·K·f·E / Cm)의 파라미터 기호. 이 기호만 있는 표는 계수표다.
|
||||
COEFFICIENT_ROW_KEYS = {"K", "f", "E", "Cm", "㎝(sec)", "q", "qo", "Q", "V", "L"}
|
||||
# 품셈 1장(적용기준)·할증·공제·단위 표준 — 공종이 아니라 **기준표**다.
|
||||
# ⚠ **「단 위」를 뺐다** (2026-09-07). 원래 겨냥은 품셈 1-2-2 「단위 표준」 표였는데,
|
||||
# **1장은 chapter 규칙이 이미 참조로 거르므로** 이 표지는 넓기만 했다. 그 탓에
|
||||
# **41건이 「헤더 '단 위'」 하나로 참조가 되어 버려지고 있었고**, 그 안에
|
||||
# **돌쌓기(장비) 13-4-5·13-4-2**(우리 매핑이 실제로 쓰는 코드) · 12-2 표면 마무리 ·
|
||||
# 9-19-1 면고르기 · 9-20 뿌리다듬기 · 13-8 막돌쌓기 같은 **명백한 공종**이 섞여 있었다.
|
||||
# 「단위 열이 있다」는 소요량표의 흔한 모양이지 참조표의 표지가 아니다.
|
||||
REFERENCE_MARKS = ("할증률", "할인", "공제율", "적재량", "지위")
|
||||
# 값 대신 「어디를 따르라」고만 적은 표. 품이 아니므로 공종으로 세우지 않는다.
|
||||
REFERENCE_CELL_MARKS = ("별도계상", "구역화물", "적용한다", "따른다", "준용")
|
||||
# 품셈이 실어 둔 **빈 단가산출서 서식** — 값이 없는 예시 양식이다(2026-09-08 ㉙, 서브가 찾음).
|
||||
# 첫 줄이 「위치 및 임·소반」·「위치 및 면적」이고 다음 줄이 「구분 | 작업량 | 단위품 |
|
||||
# 소요품 | 단가」인 모양. 채워 넣으라고 둔 칸이라 **자원도 값도 없다.**
|
||||
# ⚠ 이것을 공종으로 세우면 「값이 있는데 안 서는 자리」로 오해된다 — 애초에 값이 없다.
|
||||
# 서브가 그 표들 탓에 못 맞춘 자원이 882 → 497 로 부풀어 있었다.
|
||||
# ⚠ **두 조건을 다 만족할 때만** 본다 — 「구분」·「단가」는 정상 표에도 흔한 낱말이라
|
||||
# 하나만 보면 정상 표를 지운다(오늘 그 병을 열한 번 겪었다).
|
||||
#: 표지는 **머리글에 그대로 적혀 있다** — 「ha당 참나무시들음병방제 **단가산출서(예시)**」.
|
||||
#: 낱말 하나로 충분히 좁다(정상 표 머리글에 「단가산출서」가 올 일이 없다).
|
||||
BLANK_FORM_MARK = "단가산출서"
|
||||
#: 머리글이 비어 있는 판을 대비한 보조 표지 — **두 조건을 다 만족할 때만** 본다.
|
||||
BLANK_FORM_HEAD_MARKS = ("위치및임소반", "위치및면적")
|
||||
BLANK_FORM_BODY_MARKS = ("단위품", "소요품")
|
||||
# 직종이 값의 주인인 표 = 소요량형. `보통인부(인)`·`콘크리트공(인)` 처럼 `(인)` 이 붙는다.
|
||||
# ⚠ 원문에 `인 부` 처럼 낱말 사이 공백이 있어, 비교 전에 공백을 모두 지운다(`squeeze`).
|
||||
OCCUPATION_RE = re.compile(
|
||||
r"\((?:인|조|인/일|인·일)\)|인부|기능공|운전사|특별인부|보통인부|콘크리트공|철근공|石工|석공|목공|용접공"
|
||||
)
|
||||
# 재료 소요량표의 값 단위. 직종이 없어도 이 단위가 값 열에 오면 소요량형이다.
|
||||
MATERIAL_UNIT_MARKS = ("(kg)", "(㎏)", "(개)", "(매)", "(본)", "(ℓ)", "(L)", "(㎥)", "(㎡)", "(m)")
|
||||
|
||||
# ⚠ **첫 칸이 분류 딱지이고 이름이 둘째 칸인 표** (2026-09-07 서브 창이 자기 파싱에서 잡은 모양).
|
||||
# `['자재', 'PVC 지수판(200×5)', 'm', '1.04']` 처럼 첫 칸이 값의 이름이 아니라 갈래다.
|
||||
# 그런 표는 직종이 넷째·다섯째 행에 있어 **첫 세 행만 보는 판정에 안 걸렸고**, 12장 임도
|
||||
# 구조물 표 9건이 통째로 `undetermined` 로 빠져 있었다. 딱지를 알아보고 다시 본다.
|
||||
CLASSIFICATION_TAGS = frozenset(
|
||||
{
|
||||
"자재",
|
||||
"재료",
|
||||
"재료비",
|
||||
"자재비",
|
||||
"잡재료",
|
||||
"장비",
|
||||
"기계",
|
||||
"인력",
|
||||
"노무",
|
||||
"노무비",
|
||||
"인건비",
|
||||
"설치비",
|
||||
"경비",
|
||||
"공구손료",
|
||||
}
|
||||
)
|
||||
# 딱지 가운데 **품이 붙는 쪽**. 이 딱지가 있으면 그 표는 소요량형이다.
|
||||
RESOURCE_TAGS = frozenset({"자재", "재료", "잡재료", "장비", "기계", "인력", "노무", "공구손료"})
|
||||
# 값이 아니라 **다른 값의 몇 %** 라고만 적은 표. 품이 아니므로 참조로 둔다.
|
||||
RATIO_DIRECTIVE_MARKS = ("재료비의", "주재료비의", "회기준")
|
||||
|
||||
|
||||
def norm(text: Any) -> str:
|
||||
"""공백을 하나로 줄인 문자열. `None` 은 빈 문자열."""
|
||||
return " ".join(str(text).split()) if text is not None else ""
|
||||
|
||||
|
||||
def detect_form(table: dict[str, Any], chapter: str | None) -> tuple[str, str]:
|
||||
"""`(pum_form, 근거 문구)`. 판정 못 하면 `("undetermined", 이유)`.
|
||||
|
||||
순서가 뜻을 가진다 — 좁은 표지부터 본다. 계수·기준표를 먼저 걸러 내야
|
||||
「작업능력」 같은 흔한 낱말이 기준표를 공종으로 오인하지 않는다.
|
||||
"""
|
||||
hay = " ".join(norm(h) for h in table.get("headers", []))
|
||||
keys = [norm(r[0]) for r in table.get("rows", []) if r]
|
||||
head_rows = [norm(c) for r in table.get("rows", [])[:3] for c in r]
|
||||
first_rows = " ".join(head_rows)
|
||||
both = f"{hay} || {first_rows}"
|
||||
squeezed = both.replace(" ", "") # `인 부` → `인부`. 원문 자간 공백을 지운 뒤 비교한다.
|
||||
|
||||
for mark in COEFFICIENT_MARKS:
|
||||
if mark in hay:
|
||||
return "coefficient", f"헤더 '{mark}'"
|
||||
if keys and set(keys) <= COEFFICIENT_ROW_KEYS:
|
||||
return "coefficient", f"행 키가 시공능력 공식 기호뿐 {sorted(set(keys))}"
|
||||
if keys and all(re.fullmatch(r"[a-zA-Zqf][₀-₉0-9]?", k) for k in keys):
|
||||
return "coefficient", f"행 키가 기호뿐 {sorted(set(keys))}"
|
||||
|
||||
# 1장은 적용기준 장 자체다 — 공종이 아니라 기준표로 둔다(PLAN 8-14 「목록은 규정에서」).
|
||||
if chapter == "1":
|
||||
return "reference", "품셈 제1장(적용기준)"
|
||||
for mark in REFERENCE_MARKS:
|
||||
if mark in hay:
|
||||
return "reference", f"헤더 '{mark}'"
|
||||
for mark in REFERENCE_CELL_MARKS:
|
||||
if mark in squeezed:
|
||||
return "reference", f"'{mark}' — 값이 아니라 참조 지시"
|
||||
|
||||
# 빈 단가산출서 서식 — 머리글 표지가 정본, 머리 두 줄은 보조.
|
||||
if BLANK_FORM_MARK in hay.replace(" ", ""):
|
||||
return "reference", f"'{BLANK_FORM_MARK}' — 채워 넣으라고 둔 빈 서식"
|
||||
head2 = "".join(
|
||||
norm(str(c)) for row in (table.get("rows") or table.get("raw_row") or [])[:2] for c in row
|
||||
)
|
||||
for mark in ("․", "·", "ㆍ", " "):
|
||||
head2 = head2.replace(mark, "")
|
||||
if any(m in head2 for m in BLANK_FORM_HEAD_MARKS) and any(
|
||||
m in head2 for m in BLANK_FORM_BODY_MARKS
|
||||
):
|
||||
return "reference", "품셈이 실어 둔 빈 단가산출서 서식 — 채워 넣으라고 둔 칸"
|
||||
|
||||
# ⚠ 생산량형을 **직종보다 먼저** 본다. 「작업능력(㎥/hr)」 표의 비고란에 흔히
|
||||
# 「보통인부 1인/일」이 붙어 있어, 직종을 먼저 보면 생산량형이 소요량형으로 뒤집힌다.
|
||||
# 그 뒤집힘이 곧 PLAN 8-6 이 경고한 「값이 조용히 반대로 들어가는」 사고다.
|
||||
for mark in PRODUCTIVITY_MARKS:
|
||||
if mark in hay:
|
||||
return "productivity", f"헤더 '{mark}'"
|
||||
|
||||
# 직종이 값의 주인이면 소요량형이다 — 「보통인부(인) 0.16」 은 ㎥당 품이다.
|
||||
if OCCUPATION_RE.search(squeezed):
|
||||
return "requirement", "직종 표기((인)·인부·공)"
|
||||
for mark in MATERIAL_UNIT_MARKS:
|
||||
if mark in hay:
|
||||
return "requirement", f"값 단위 '{mark}'"
|
||||
|
||||
for mark in PRODUCTIVITY_MARKS:
|
||||
if mark in both:
|
||||
return "productivity", f"본문 '{mark}'"
|
||||
for mark in REQUIREMENT_MARKS:
|
||||
if mark in both:
|
||||
return "requirement", f"'{mark}'"
|
||||
|
||||
# ⚠ 첫 칸이 분류 딱지인 표 — **맨 마지막에 본다.** 앞의 판정을 흔들지 않으려는 자리다.
|
||||
tags = [norm(row[0]).replace(" ", "") for row in table.get("rows", []) if row]
|
||||
if any(tag in CLASSIFICATION_TAGS for tag in tags):
|
||||
# ⚠ **소요량을 먼저 본다.** 비율 지시를 먼저 보면 `잡재료비(재료비의) 5 %` 한 줄 때문에
|
||||
# 강관동바리(내관 0.38본·형틀목공 0.07인) 같은 **멀쩡한 소요량표가 참조로 넘어간다**
|
||||
# — 만들다 실제로 걸린 자리다.
|
||||
# 값이 실제로 있는 표만 공종으로 세운다. 이름만 있고 수치가 없으면 판정하지 않는다.
|
||||
has_number = any(
|
||||
re.fullmatch(r"[\d,]+(?:\.\d+)?", norm(c)) for r in table.get("rows", []) for c in r
|
||||
)
|
||||
if has_number and any(tag in RESOURCE_TAGS for tag in tags):
|
||||
return "requirement", f"분류 딱지 {sorted({t for t in tags if t in RESOURCE_TAGS})}"
|
||||
body = " ".join(norm(c) for r in table.get("rows", []) for c in r).replace(" ", "")
|
||||
for mark in RATIO_DIRECTIVE_MARKS:
|
||||
if mark in body:
|
||||
return "reference", f"분류 딱지 표의 '{mark}' — 값이 아니라 비율 지시"
|
||||
|
||||
return "undetermined", "헤더·첫 행에 단위·밑수·직종 표지 없음"
|
||||
|
||||
|
||||
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건이 잘못 잡혔다.
|
||||
#: ⚠ `(단위: 인/㎡당)` 꼴 — **분모가 밑수**다. 값의 단위(인)를 밑수로 읽으면 뜻이 뒤집힌다.
|
||||
#: ⚠ 「단위:」 없이 `(인/100m당)` 으로만 적은 표가 22 곳이다(조림 5장·단끊기 5-16-1 등, 2026-09-13).
|
||||
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
|
||||
|
||||
#: 표 **아래 [주]** 에만 밑수가 적힌 자리 — 「목재의 손율은 **1개소 사용당** 50%로 한다」.
|
||||
#: ⚠ **아주 좁게 잡는다.** 「N개소 **사용** 당」이라는 표기는 품셈 전문에 **딱 두 곳**뿐이고
|
||||
#: (11-2 비탈 규준틀 · 11-3 수평 규준틀) 둘 다 개소로 세는 표다. 「개소당 평균면적」 같은
|
||||
#: 흔한 말은 「사용」이 없어 안 걸린다 — 넓히면 조림 2장이 통째로 걸린다(실측 19건).
|
||||
#: ⚠ 뜻으로도 개소가 맞다 — [주]② 가 「본 품은 …한 **비탈규준틀**의 제작·도색·가설·철거를
|
||||
#: 포함한 것」이라 **규준틀 하나**를 말하고, [주]① 이 「20m마다 **설치**」라 센다.
|
||||
SOURCE_BASIS_NOTE_RE = re.compile(r"([\d,]*\.?\d*)\s*개소\s*사용\s*당")
|
||||
#: 표 아래로 몇 줄까지 볼 것인가. [주] 는 표 바로 밑에 붙는다.
|
||||
SOURCE_NOTE_LOOKAHEAD = 8
|
||||
|
||||
|
||||
#: ⚠⚠ **「인」은 품의 단위이지 공종 밑수가 아니다**(2026-09-09 원문 대조로 넷이 오독으로 드러남).
|
||||
#: 8-6-2 드론방제·8-6-3 지상방제 「(단위 : 인)」 — 그 「인」은 **소요인력**이고 공종 밑수는
|
||||
#: ha 다. 13-2-4 야면석 채집 「(단위: 인 당)」 — 표 안이 **㎡당·㎥당** 두 줄이다.
|
||||
#: ⇒ **원문에 「<숫자>인당」이 그대로 있을 때만** 밑수로 인정한다.
|
||||
#: ⚠ **넓게 「인」을 통째로 버리면 2-2-5 천공기가 사라진다** — 그 표는 셀 안에 「천공인부
|
||||
#: **1인당** 1대」로 숫자가 붙어 있다. 그래서 「숫자가 붙었나」로 좁게 가른다.
|
||||
PERSON_UNITS = {"인", "공"}
|
||||
|
||||
|
||||
def person_basis_ok(raw_quantity: str | None, unit: str | None) -> bool:
|
||||
"""「인」 계열 밑수를 인정할 것인가 — **숫자가 붙어 있을 때만** 참."""
|
||||
if unit not in PERSON_UNITS:
|
||||
return True
|
||||
return bool((raw_quantity or "").strip())
|
||||
|
||||
|
||||
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(",", "")
|
||||
if not person_basis_ok(raw, m.group(2)):
|
||||
continue # 「인」에 숫자가 안 붙었다 — 품의 단위이지 밑수가 아니다
|
||||
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(",", "")
|
||||
if not person_basis_ok(raw, unit):
|
||||
continue # 위와 같음 — 「(단위 : 인)」·「(단위: 인 당)」은 밑수가 아니다
|
||||
try:
|
||||
quantity = float(raw) if raw else 1.0
|
||||
except ValueError:
|
||||
quantity = 1.0
|
||||
return quantity, unit
|
||||
|
||||
# 표 **아래 [주]** 도 본다 — 위에 아무것도 없을 때만. 규준틀 둘이 그 자리다.
|
||||
# ⚠ 다음 표(`|`)나 다음 절(`###`)을 만나면 멈춘다 — 남의 [주]를 물어 오면 조용히 틀린다.
|
||||
for index in range(line_no, min(len(lines), line_no + SOURCE_NOTE_LOOKAHEAD)):
|
||||
text = lines[index].strip()
|
||||
if text.startswith("###"):
|
||||
break
|
||||
if m := SOURCE_BASIS_NOTE_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, "개소"
|
||||
return None, None
|
||||
|
||||
|
||||
#: ⚠ **밑수가 절 이름에만 있는 표** — 「4-2-2. 1,000㎡당」·「4-2-1. 100본당」(2026-09-13 판정).
|
||||
#: 본문·표 안 어디에도 「(단위: …)」가 없어 밑수가 비어 있었다(B09 가 막아 금액은 안 섰음).
|
||||
#: ⚠ **이름 전체가 「수 + 단위 + 당」일 때만** 뽑는다 — 「나무주사(100본당)」처럼 섞인 것은
|
||||
#: 본문이 이미 주고, 「자재의 1회당 표준 운반량」 같은 설명문은 밑수가 아니다. 짐작하지 않는다.
|
||||
NAME_BASIS_RE = re.compile(
|
||||
r"^([\d,]+(?:\.\d+)?)\s*(㎥|㎡|㏊|ha|㎞|km|m|매|본|개소|개|주|kg|㎏|톤|ton)\s*당$"
|
||||
)
|
||||
|
||||
|
||||
def basis_from_name(name: str) -> tuple[float | None, str | None]:
|
||||
"""절 이름이 곧 밑수인 경우만 `(수, 단위)`. 아니면 `(None, None)`."""
|
||||
found = NAME_BASIS_RE.match(norm(name))
|
||||
if found is None:
|
||||
return None, None
|
||||
try:
|
||||
return float(found.group(1).replace(",", "")), found.group(2)
|
||||
except ValueError:
|
||||
return None, None
|
||||
|
||||
|
||||
def detect_basis(table: dict[str, Any]) -> tuple[float | None, str | None]:
|
||||
"""「100㎥당」 같은 밑수. 없으면 `(None, None)` — 단위당 1 로 단정하지 않는다.
|
||||
|
||||
⚠⚠ **칸 하나 안에서만 본다**(2026-09-09). 여러 칸을 이어 붙여 보면 **앞 칸의 값**과
|
||||
**뒤 칸의 단위**가 붙어 없는 밑수가 생긴다 — 13-2-4 야면석 채집이 그랬다:
|
||||
행 ① 인 부 | ㎡당 | 0.11 | 0.17 | 0.22 | 0.28 | **0.36**
|
||||
행 ② **㎥당** | 0.60 | …
|
||||
이어 붙이면 「0.36 ㎥당」이 되어 **밑수 0.36㎥** 라는 값이 선다(원문에 없는 값).
|
||||
⚠ 그 표는 **㎡당·㎥당 두 줄**이라 애초에 하나로 못 정한다 — 「미확보」가 정직하다.
|
||||
⚠ **「인」은 여기서도 숫자가 붙어야 인정한다** — `BASIS_RE` 가 숫자를 요구하므로
|
||||
2-2-5 「천공인부 **1인당** 1대」는 그대로 서고 「(단위 : 인)」은 안 선다.
|
||||
"""
|
||||
cells = [norm(h) for h in table.get("headers", []) or []]
|
||||
cells += [norm(c) for r in (table.get("rows", []) or [])[:2] for c in r]
|
||||
for cell in cells:
|
||||
if not cell:
|
||||
continue
|
||||
if m := BASIS_RE.search(cell):
|
||||
if not person_basis_ok(m.group(1), m.group(2)):
|
||||
continue
|
||||
try:
|
||||
return float(m.group(1).replace(",", "")), m.group(2)
|
||||
except ValueError:
|
||||
return None, m.group(2)
|
||||
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"}
|
||||
|
||||
|
||||
# ⚠ **값 자리에 숫자가 아니라 식이 적힌 칸** (2026-09-07 서브 창 제보로 확인).
|
||||
# `0.2 × 30%`(기초잡석 소할) · `(0.2+0.26)/2` · `1/1.3` 처럼 계산이 그대로 적혀 있다.
|
||||
# 형태 판정은 통과하는데 **값이 숫자로 안 읽혀 그 성분이 조용히 빠진다** —
|
||||
# `partial_ratio` 와 같은 병인데 배분율 딱지가 없어 아무 검사에도 안 걸렸다.
|
||||
# ⚠ 여기서 **식을 계산하지 않는다.** 뜻을 잘못 읽으면 조용히 틀리므로 **드러내기만** 한다.
|
||||
_PLAIN_NUMBER_RE = re.compile(r"^[\d,]+(?:\.\d+)?$")
|
||||
_CALC_CELL_RE = re.compile(r"^[\d,.\s()×xX*/÷+\-%]+$")
|
||||
_CALC_MAX_LEN = 22
|
||||
|
||||
|
||||
def expression_cells(table: dict[str, Any]) -> list[str]:
|
||||
"""값 자리에 식이 적힌 칸 목록. 없으면 빈 목록."""
|
||||
found: list[str] = []
|
||||
for row in table.get("rows", []):
|
||||
for cell in row:
|
||||
text = norm(cell)
|
||||
if not text or len(text) > _CALC_MAX_LEN or _PLAIN_NUMBER_RE.match(text):
|
||||
continue
|
||||
if (
|
||||
_CALC_CELL_RE.match(text)
|
||||
and re.search(r"\d", text)
|
||||
and re.search(r"[×xX*/÷+]", text)
|
||||
):
|
||||
found.append(text)
|
||||
# 순서를 지키되 중복은 지운다 — 같은 식이 여러 줄에 반복된다.
|
||||
seen: list[str] = []
|
||||
for item in found:
|
||||
if item not in seen:
|
||||
seen.append(item)
|
||||
return seen
|
||||
|
||||
|
||||
# ⚠ **작업조 표** — 「형틀목공 4인 + 보통인부 1인 / 시공량 25·35·40㎡」처럼
|
||||
# **인원과 시공량**으로 적힌 표다. 그 「4」는 소요량이 아니라 **작업조 인원**이라
|
||||
# 행-자원으로 그냥 읽으면 **35배 부푼다**(유로폼 12-38-3 이 그 표다).
|
||||
# ⚠ 값을 바꾸지 않고 **표시만** 한다 — 나누는 것은 받는 쪽 몫이다.
|
||||
CREW_QUANTITY_MARKS = ("시공량", "작업량", "1일작업량")
|
||||
|
||||
|
||||
# ⚠ **이름에 자간 공백이 든 기종·직종** — 같은 표 묶음 안에서도 표기가 갈린다
|
||||
# (`13-6-1` 은 「굴 삭 기 (무한궤도)」, 바로 옆 `13-6-2` 는 「굴착기 (무한궤도)」).
|
||||
# 받는 쪽이 이름으로 자원을 찾으므로 **표기가 갈리면 그 줄이 통째로 빠진다.**
|
||||
# ⚠ **여기서 이름을 고치지 않는다** — 정규화는 값을 살리지만 **잘못된 줄도 함께 살린다**
|
||||
# (서브 창 실례: 이름 정규화 직후 버킷계수 `K` 를 소요량으로 읽어 시간당 사용료가 이중).
|
||||
# 깃발만 실어 받는 쪽이 대조하게 한다.
|
||||
_SPACED_NAME_RE = re.compile(r"^(?=.*\S\s\S)[가-힣](?:\s+[가-힣])+")
|
||||
#: 시공능력 공식 파라미터가 값 자리에 온 줄 — **소요량이 아니다.** 그냥 읽으면 이중계상.
|
||||
_FORMULA_KEYS = frozenset({"K", "k", "f", "E", "Cm", "q", "qo", "Q", "㎝(sec)"})
|
||||
#: 자원 줄임을 알리는 단위 칸. 이것과 수치가 함께 있어야 자원으로 본다.
|
||||
_RESOURCE_UNITS = frozenset(
|
||||
{"인", "h", "hr", "시간", "대", "매", "본", "개", "kg", "㎏", "㎥", "㎡", "m", "ℓ", "톤", "ton"}
|
||||
)
|
||||
|
||||
|
||||
def spaced_names(table: dict[str, Any]) -> list[str]:
|
||||
"""자간 공백이 든 **자원 이름**. 표기가 갈리는 자리를 드러낸다.
|
||||
|
||||
⚠ **좁게 잡는다.** 「단 위」·「모 래」 같은 머리글·재료명까지 걸면 101건이 되어
|
||||
목록이 잡음이 되고, 잡음이 되면 아무도 안 본다(오늘 아홉 번 겪은 병).
|
||||
**그 줄에 단위 칸과 수치가 함께 있는 것**만 자원 줄로 본다.
|
||||
"""
|
||||
found: list[str] = []
|
||||
for row in table.get("rows", []):
|
||||
if not row:
|
||||
continue
|
||||
name = norm(row[0])
|
||||
if not name or not _SPACED_NAME_RE.match(name):
|
||||
continue
|
||||
rest = [norm(cell) for cell in row[1:]]
|
||||
has_unit = any(cell in _RESOURCE_UNITS for cell in rest)
|
||||
has_number = any(_PLAIN_NUMBER_RE.match(cell) for cell in rest if cell)
|
||||
if has_unit and has_number and name not in found:
|
||||
found.append(name)
|
||||
return found
|
||||
|
||||
|
||||
def formula_rows(table: dict[str, Any]) -> list[str]:
|
||||
"""값 자리에 시공능력 공식 기호가 온 줄. 자원으로 세면 이중계상이다."""
|
||||
found: list[str] = []
|
||||
for row in table.get("rows", []):
|
||||
for cell in row:
|
||||
text = norm(cell)
|
||||
if text in _FORMULA_KEYS and text not in found:
|
||||
found.append(text)
|
||||
return found
|
||||
|
||||
|
||||
# ⚠ **규격 표기에 쓰이는 특수문자** — 원문이 한 종류로 안 쓴다(2026-09-07 실측).
|
||||
# 물결표만 셋이다: `∼`(U+223C) 662회 · `~`(U+FF5E) 459회 · `~`(U+007E) 2회.
|
||||
# 곱셈표도 `×`(U+00D7) 97회 · `x`(U+0078) 4회로 갈린다.
|
||||
# **두 창이 각자 갈래 키를 조립하면 글자 하나로 영영 안 맞는다** — 그래서 우리는
|
||||
# 키를 조립하지 않고 **저장 원본값만** 보낸다(인계 계약). 여기서는 **표시만** 한다.
|
||||
# ⚠ 값을 고치지 않는다 — 원문 표기를 흡수하는 것은 **원문을 읽는 쪽**의 몫이다.
|
||||
SPECIAL_GLYPHS = {
|
||||
"∼": "∼(U+223C)",
|
||||
"~": "~(U+FF5E)",
|
||||
"~": "~(U+007E)",
|
||||
"×": "×(U+00D7)",
|
||||
"x": "x(U+0078)",
|
||||
"–": "–(U+2013)",
|
||||
"‐": "‐(U+2010)",
|
||||
}
|
||||
|
||||
|
||||
def special_glyphs(table: dict[str, Any]) -> list[str]:
|
||||
"""규격 표기에 쓰인 특수문자 종류. 갈래 키를 맞출 때 대조할 자리."""
|
||||
text = " ".join(norm(cell) for row in table.get("rows", []) for cell in row)
|
||||
return sorted({SPECIAL_GLYPHS[ch] for ch in text if ch in SPECIAL_GLYPHS})
|
||||
|
||||
|
||||
def crew_table(table: dict[str, Any]) -> bool:
|
||||
"""작업조 + 시공량으로 적힌 표인가."""
|
||||
hay = " ".join(norm(h) for h in table.get("headers", []))
|
||||
body = " ".join(norm(c) for row in table.get("rows", []) for c in row)
|
||||
squeezed = (hay + " " + body).replace(" ", "")
|
||||
if not any(mark in squeezed for mark in CREW_QUANTITY_MARKS):
|
||||
return False
|
||||
return bool(OCCUPATION_RE.search(squeezed))
|
||||
|
||||
|
||||
def resource_shares(table: dict[str, Any]) -> dict[str, float]:
|
||||
"""`{인력: 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 basis_quantity_is_grouped(quantity: float | None) -> bool:
|
||||
"""「10㎡당」처럼 **묶음 기준**인가. 1 이 아니면 곱셈이 그만큼 갈린다."""
|
||||
return quantity is not None and abs(quantity - 1.0) > 1e-9
|
||||
|
||||
|
||||
def new_report() -> dict[str, Any]:
|
||||
"""표 읽기 셈 — 밑수 확보·묶음 기준 수 · 형태 못 정한 표 · 밑수 못 찾은 표.
|
||||
|
||||
⚠ 밑수를 못 찾은 표 목록은 「곱하면 안 되는 줄」을 받는 쪽이 가릴 수 있게 낸다 —
|
||||
빈칸으로 두면 「1단위당」으로 오해되어 곱셈이 10배·100배 틀린다.
|
||||
"""
|
||||
return {"basis_found": 0, "basis_grouped": 0, "basis_missing": [], "undetermined": []}
|
||||
|
||||
|
||||
def table_entry(
|
||||
table: dict[str, Any],
|
||||
section: str,
|
||||
number: str | None,
|
||||
chapter: str | None,
|
||||
source_lines: list[str],
|
||||
node_name: str | None,
|
||||
report: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""표 한 장 → 공종 마스터 표 칸(형태·밑수·깃발). 산림·건설 한 벌 — 셈은 `report` 에 쌓음.
|
||||
|
||||
`chapter` 는 형태 판정의 「1장 = 적용기준」 가름 · `node_name` 은 절 이름이 곧 밑수인 표(4-2-2 「1,000㎡당」).
|
||||
"""
|
||||
form, why = detect_form(table, chapter)
|
||||
# 사람이 가른 형태가 자동 판정을 이긴다 — 표마다 까닭 한 줄(미판정 표 판정, 2026-09-13).
|
||||
form, why = judged_form(table["table_id"], number) or (form, why)
|
||||
# ⚠ **본문이 정본이다.** 표 안을 긁는 쪽은 보조 — 비고에 적힌 다른 기준
|
||||
# (「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 = "본문"
|
||||
# 본문·표 안에 없을 때만 **절 이름**을 본다(4-2-2 「1,000㎡당」) — 이름 전체가 밑수일 때만.
|
||||
if basis_unit is None and node_name is not None:
|
||||
name_qty, name_unit = basis_from_name(node_name)
|
||||
if name_unit:
|
||||
basis_qty, basis_unit, basis_source = name_qty, name_unit, "절 이름"
|
||||
if basis_unit:
|
||||
report["basis_found"] += 1
|
||||
if basis_quantity_is_grouped(basis_qty):
|
||||
report["basis_grouped"] += 1
|
||||
elif form in ("requirement", "productivity") and not crew_table(table):
|
||||
# 참조·계수표는 곱할 값이 아니므로 목록에 넣지 않는다 — 잡음이 되면 안 본다.
|
||||
# 작업조 표도 뺌 — 밑수가 시공량 열(1단위당 = 인원 ÷ 시공량 · B09 CrewOutput)이라
|
||||
# 「N㎡당」 문구가 없어도 곱셈이 안 틀림(12-38-3 설치·해체가 여기 걸려 막혀 있었음 · 2026-09-14 301).
|
||||
report["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,
|
||||
"source_line": table.get("line"),
|
||||
"pum_form": form,
|
||||
"form_basis": why,
|
||||
"basis_quantity": basis_qty,
|
||||
"basis_unit": basis_unit,
|
||||
# 밑수를 어디서 읽었나 — 본문(정본) / 표 안(보조). 없으면 None.
|
||||
"basis_source": basis_source,
|
||||
"resource_shares": shares,
|
||||
"partial_ratio": partial,
|
||||
# ⚠ 값 자리에 식이 적힌 칸 — 그 성분은 숫자로 안 읽히므로 **금액을 만들면 안 된다**.
|
||||
"expression_cells": expression_cells(table),
|
||||
# ⚠ 작업조 표 — 「4」가 소요량이 아니라 인원이다. 그냥 읽으면 35배 부푼다.
|
||||
"crew_table": crew_table(table),
|
||||
# ⚠ 이름 표기가 갈리는 줄 — 받는 쪽이 이름으로 찾으면 통째로 빠진다.
|
||||
"spaced_names": spaced_names(table),
|
||||
# ⚠ 공식 기호 줄 — 소요량이 아니다. 자원으로 세면 이중계상.
|
||||
"formula_rows": formula_rows(table),
|
||||
# ⚠ 규격 표기 특수문자 — 갈래 키를 맞출 때 대조할 자리(값은 안 고침).
|
||||
"special_glyphs": special_glyphs(table),
|
||||
# 공식 기호가 이 표에 직접 있는가 — 없으면 앞 표에서 물려받는 모양이다.
|
||||
"capacity_formula_here": capacity_formula_pending(table),
|
||||
# 갈래 키 — 부모 모양을 단 뒤 표 읽기가 가른 대로 채움(`_Variants`). 못 가르면 빈칸.
|
||||
"variant_key": [],
|
||||
"condition_note": [norm(h) for h in table.get("headers", []) if norm(h)],
|
||||
"raw_row": table.get("rows", []), # 원문 셀 — B09 자원 축이 읽는다.
|
||||
}
|
||||
if form == "undetermined":
|
||||
report["undetermined"].append(
|
||||
{
|
||||
"pum_table_id": table["table_id"],
|
||||
"section": section,
|
||||
"headers": entry["condition_note"],
|
||||
"first_rows": table.get("rows", [])[:2],
|
||||
"reason": why,
|
||||
}
|
||||
)
|
||||
return entry
|
||||
@@ -249,6 +249,11 @@ def build_handoff(
|
||||
result["pum_edition"] = table.pum_edition or None
|
||||
for row in work_items:
|
||||
row.setdefault("pum_edition", table.pum_edition or None)
|
||||
# 불변 열쇠 길목(2026-09-17 8-6 이행) — 줄마다 `work_item_key`(FW-·AX-) 를 더함.
|
||||
# `work_item_code` 는 사람이 보는 목차 번호로 그대로 · 못 찾은 코드는 목록으로(지어내지 않음).
|
||||
from common_util.common_util_work_item_key import attach_keys
|
||||
|
||||
result["unkeyed_work_item_codes"] = attach_keys(work_items)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ _ZERO = Decimal(0)
|
||||
|
||||
# ⬇ 인계 읽기는 `_Input` 으로 옮겼다(2026-09-15 700줄 분리) — 쓰던 이름이 그대로 살게 다시 내보낸다.
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities_Input import ( # noqa: E402,F401
|
||||
BLOCKED_LINK_UNDECIDED,
|
||||
BLOCKED_UNCONFIRMED,
|
||||
SUPPLY_OWNER,
|
||||
SUPPLY_UNKNOWN,
|
||||
@@ -56,6 +57,7 @@ from B09_Estimation.B09_Estimation_BillOfQuantities_Input import ( # noqa: E402
|
||||
_decimal,
|
||||
parse_handoff,
|
||||
)
|
||||
from common_util.common_util_work_item_link import undecided_links # noqa: E402
|
||||
|
||||
#: 총 절취량으로 셀 공종 — 9-3 토사깎기 · 9-4 암절취 · 9-5 발파암(인계 대응표 「흙깎기」 셋).
|
||||
#: ⚠ 판에 묶인 절 번호임(명세 17장) — 판이 바뀌면 대응표와 함께 고칠 것.
|
||||
@@ -75,6 +77,7 @@ _BLOCKED_LABELS = {
|
||||
"input_missing": "입력이 필요합니다",
|
||||
"unit_data_missing": "원단위가 없습니다(우리가 만들 것)",
|
||||
"formula_missing": "전개식이 없습니다(우리가 만들 것)",
|
||||
BLOCKED_LINK_UNDECIDED: "연결고리 미판정 — 금액에 안 들어감",
|
||||
}
|
||||
|
||||
|
||||
@@ -353,6 +356,18 @@ def build_bill(
|
||||
}
|
||||
)
|
||||
continue
|
||||
if item.blocked_kind == BLOCKED_LINK_UNDECIDED:
|
||||
# 산림·건설 어느 품을 쓸지 연결고리 표가 미판정 — 근거 없이 고르지 않음(브레인 · 코덱스 ②).
|
||||
result.missing.append(
|
||||
{
|
||||
"name": item.display_name,
|
||||
"unit": item.unit,
|
||||
"quantity": str(item.quantity),
|
||||
"reason": f"{_BLOCKED_LABELS[BLOCKED_LINK_UNDECIDED]} — {item.blocked_reason}",
|
||||
"blocked_kind": BLOCKED_LINK_UNDECIDED,
|
||||
}
|
||||
)
|
||||
continue
|
||||
if (item.composite_parts or item.composite_not_ready) and not item.work_item_code:
|
||||
# 묶음 줄 — 품셈에 그 공종이 없어 **조각을 합쳐** 한 줄로 세운다
|
||||
# (옹벽 = 타설 + 거푸집 + 철근 + 잡석). 「코드 없음」으로 세면 안 된다.
|
||||
@@ -577,6 +592,8 @@ def bill_summary(result: BillResult) -> dict[str, Any]:
|
||||
"direct_labor_krw": str(result.direct_labor_krw),
|
||||
"direct_expense_krw": str(result.direct_expense_krw),
|
||||
"notes": result.notes,
|
||||
# 연결고리 표 미판정 행 — 열쇠가 비어 줄에 못 닿는 것도 드러냄(조용히 안 버림)
|
||||
"link_undecided": undecided_links(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,12 @@ from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_work_item_key import work_item_code_of, work_item_key
|
||||
from common_util.common_util_work_item_link import choose
|
||||
|
||||
#: 연결고리 표 미판정 — 산림·건설 어느 품을 쓸지 근거가 없어 **안 고른** 줄(금액에 안 듦).
|
||||
BLOCKED_LINK_UNDECIDED = "link_undecided"
|
||||
|
||||
_ZERO = Decimal(0)
|
||||
|
||||
#: 자재 공급 구분이 안 갈린 값. B08 이 실제로 이 값을 보낸다(2026-09-08 실물 확인).
|
||||
@@ -78,6 +84,9 @@ class HandoffWorkItem:
|
||||
spec_class_basis: str = ""
|
||||
#: 이 줄의 공종 코드가 본 품셈 판 — 마스터 판과 다르면 값을 안 씀(명세 17장).
|
||||
pum_edition: str = ""
|
||||
#: 불변 열쇠(FW-·AX-) — 목차 번호(`work_item_code`)가 개정으로 밀려도 안 바뀜(8-6 이행 길목).
|
||||
#: ⚠ 금액 셈은 아직 `work_item_code` 로 함 — 이 칸은 더하기만(630곳 안 고침).
|
||||
work_item_key: str = ""
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
@@ -116,6 +125,23 @@ def _decimal(value: Any, default: Decimal | None = _ZERO) -> Decimal | None:
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def _linked(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""열쇠 길목 + **연결고리 표** — 산림·건설 어느 품을 쓸지 표가 정함(고시 총칙 7 · 표 `policy`).
|
||||
|
||||
둘 다 있는 공종이면 표가 적은 우선 품셈 열쇠·목차 코드로 바꿈 · 미판정이면 막음(사유 그대로).
|
||||
⚠ 이미 B08 이 막아 보낸 줄은 그 까닭을 덮지 않음.
|
||||
"""
|
||||
edition = row.get("pum_edition") or None
|
||||
key = row.get("work_item_key") or work_item_key(row.get("work_item_code"), edition)
|
||||
choice = choose(key)
|
||||
out = {"work_item_key": key or "", "work_item_code": row.get("work_item_code")}
|
||||
if choice.blocked and not row.get("blocked_reason"):
|
||||
out.update(blocked_reason=choice.blocked, blocked_kind=BLOCKED_LINK_UNDECIDED)
|
||||
elif choice.key and choice.key != key:
|
||||
out.update(work_item_key=choice.key, work_item_code=work_item_code_of(choice.key, edition))
|
||||
return out
|
||||
|
||||
|
||||
def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[HandoffMaterial]]:
|
||||
"""인계 응답을 우리 자료형으로 옮긴다. **모르는 칸을 채우지 않는다.**"""
|
||||
if "work_items" not in payload or "materials" not in payload:
|
||||
@@ -123,7 +149,7 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[
|
||||
|
||||
work_items = [
|
||||
HandoffWorkItem(
|
||||
work_item_code=row.get("work_item_code"),
|
||||
work_item_code=linked["work_item_code"],
|
||||
name=row.get("name", ""),
|
||||
spec=row.get("spec") or "",
|
||||
unit=row.get("unit") or "",
|
||||
@@ -142,15 +168,18 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[
|
||||
composite_parts=tuple(row.get("composite_parts") or ()),
|
||||
composite_not_ready=tuple(row.get("composite_not_ready") or ()),
|
||||
structure_kind=row.get("structure_kind") or "",
|
||||
blocked_reason=row.get("blocked_reason") or "",
|
||||
blocked_kind=row.get("blocked_kind") or "",
|
||||
blocked_reason=row.get("blocked_reason") or linked.get("blocked_reason") or "",
|
||||
blocked_kind=row.get("blocked_kind") or linked.get("blocked_kind") or "",
|
||||
variant_axis=row.get("variant_axis") or "",
|
||||
variant_value=str(row.get("variant_value") or ""),
|
||||
spec_class=row.get("spec_class") or "",
|
||||
spec_class_basis=row.get("spec_class_basis") or "",
|
||||
pum_edition=str(row.get("pum_edition") or ""),
|
||||
# 옛 인계(열쇠 없음)도 같은 길목으로 채움 · 연결고리 표가 고른 열쇠
|
||||
work_item_key=linked["work_item_key"],
|
||||
)
|
||||
for row in payload["work_items"]
|
||||
for linked in (_linked(row),)
|
||||
]
|
||||
materials = [
|
||||
HandoffMaterial(
|
||||
|
||||
@@ -19,6 +19,14 @@ from __future__ import annotations
|
||||
|
||||
#: 공종코드 → (짧은 딱지, 사유 한 줄). 접두사로 맞춘다(갈래가 붙어도 걸리게).
|
||||
KNOWN_GAPS: dict[str, tuple[str, str]] = {
|
||||
# ⚠ 2026-09-17 — 찰붙임 장비 표(F0418)가 [주] 인용을 제목으로 읽혀 13-3 헛단위에 붙어 있다가
|
||||
# 제자리(13-5-2)를 찾으니 **메붙임(F0417)·찰붙임(F0418) 두 표가 같은 갈래 이름(35·55·75cm 이하)으로
|
||||
# 한 단가에 더해짐**(약 두 배). 지금 이 코드를 쓰는 곳은 0 — 갈래를 메/찰로 가르기 전 쓰지 말 것.
|
||||
"FP-13-05-02": (
|
||||
"메·찰 합쳐짐",
|
||||
"⚠ 이 단가는 **메붙임·찰붙임 두 표가 더해진 값**입니다 — 둘 중 하나만 써야 하는 갈래라 "
|
||||
"지금 값은 쓰면 안 됩니다(품셈 13-5-2 「1. 메붙임」·「2. 찰붙임」 · 갈래 가르기 대기).",
|
||||
),
|
||||
# ⚠ 규준틀 둘은 **B08 인계가 이미 「재료량은 [주]④ 설계수량에 따른다라 미확보」**를 싣는다.
|
||||
# 그러니 그 말을 되풀이하지 않고 **B09 쪽에서만 아는 것**만 보탠다 —
|
||||
# ㉠ 지금 선 값이 **인력 품만**이라는 것 ㉡ 손율은 원문에 이미 있다는 것.
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface RowsQuery {
|
||||
q: string;
|
||||
/** 열 제목을 눌렀을 때만 — 없으면 서버가 원본 차례로 줌 */
|
||||
sort?: SortOrder | null;
|
||||
/** 사람이 고른 거르기만(`TableMeta.filters`) — 안 고른 것은 서버 기본값 */
|
||||
filters?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +89,7 @@ export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise<
|
||||
params.set("sort", query.sort.key);
|
||||
params.set("desc", query.sort.desc ? "1" : "0");
|
||||
}
|
||||
for (const [key, value] of Object.entries(query.filters ?? {})) params.set(key, value);
|
||||
return request<MasterRows>(`/master-data/base-prices/${kind}?${params}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
@@ -20,9 +21,10 @@ from Z01_MasterData import Z01_MasterData_BasePrices_Machine as machine
|
||||
from Z01_MasterData import Z01_MasterData_BasePrices_Tables as base_tables
|
||||
from Z01_MasterData import Z01_MasterData_Overrides as overrides
|
||||
from Z01_MasterData import Z01_MasterData_Tables as tables
|
||||
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
|
||||
|
||||
#: 기초단가 다섯 + 기초데이터 아홉(품셈 근거 8 · 돌쌓기 갈래 1) — 이름은 자료 이름 그대로
|
||||
KINDS = ("labor", "machine", "material", "oil", "rate", *base_tables.SPEC)
|
||||
#: 기초단가 다섯 + 기초데이터 열 + 공종 축 둘(산림·건설) — 이름은 자료 이름 그대로
|
||||
KINDS = ("labor", "machine", "material", "oil", "rate", *base_tables.SPEC, *work_items.KINDS)
|
||||
SOURCES_DIR = (
|
||||
tables.RESOURCES / "data_master_sources"
|
||||
) # 출처표(데스크탑 서브) — 어디서 받고 최신이 무엇인지
|
||||
@@ -37,8 +39,11 @@ _RATE_EDITABLE = (
|
||||
"manager_thresholds.default_estimated_amount_krw",
|
||||
"manager_thresholds.civil_main_work_estimated_amount_krw",
|
||||
)
|
||||
#: ⚠ **지금 상태를 먼저** — 덮개를 읽는 곳이 Z01 밖에 0(2026-09-17 서브 훑기 · 브레인 「없으면 없다고」).
|
||||
#: 뒤 문장은 프로젝트 복사(요구 14)가 붙으면 참이 되는 약속이라 지우지 않고 「그 길이 붙으면」으로 둠.
|
||||
_SCOPE_NOTICE = (
|
||||
"여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임 — 이미 만든 프로젝트 금액은 안 바뀜"
|
||||
"⚠ **아직 어디에도 안 쓰임** — 고친 값은 마스터에만 쌓임 · 프로젝트로 옮기는 길(프로젝트 복사)이 아직 없음."
|
||||
" 그 길이 붙으면: 여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임 — 이미 만든 프로젝트 금액은 안 바뀜"
|
||||
"(프로젝트는 만들 때 뜬 사본을 봄)"
|
||||
)
|
||||
_RATE_NOTICE = "법이 정한 값 — 고치면 원본 갱신 때 구간 이름이 바뀌어 고친 값이 주인을 잃기 쉬움(주인 없음으로 남음)"
|
||||
@@ -276,6 +281,8 @@ def base_rows(kind: str) -> list[dict[str, Any]]:
|
||||
if kind == "machine":
|
||||
doc, name = _doc("mach_base")
|
||||
return machine.base_rows(name, edition_of(doc))
|
||||
if kind in work_items.KINDS:
|
||||
return work_items.base_rows(kind)
|
||||
spec_of = base_tables.SPEC.get(kind)
|
||||
if spec_of:
|
||||
doc, name = _doc(spec_of["file"])
|
||||
@@ -323,15 +330,21 @@ class SortError(ValueError):
|
||||
"""모르는 열로 세우라 함 — 조용히 원본 차례로 돌아가지 않음(브레인 ④)."""
|
||||
|
||||
|
||||
#: 품셈 번호 꼴(「9-3-2」·「12-17」) — 글자로 세우면 「10-1」이 「2-1」 앞에 섬(서브 훑기 · 브레인).
|
||||
_NUMBER_PATH = re.compile(r"^\d+(?:-\d+)+$")
|
||||
|
||||
|
||||
def _order_key(value: Any) -> tuple:
|
||||
"""섞인 칸도 세움 — 수 먼저, 그 뒤 글자."""
|
||||
"""섞인 칸도 세움 — 수 먼저, 품셈 번호 꼴은 **마디마다 수로**, 그 뒤 글자."""
|
||||
if isinstance(value, bool):
|
||||
return (1, str(value))
|
||||
return (2, str(value))
|
||||
if isinstance(value, (int, float)):
|
||||
return (0, float(value))
|
||||
if isinstance(value, (dict, list)):
|
||||
return (1, json.dumps(value, ensure_ascii=False, default=str))
|
||||
return (1, str(value))
|
||||
return (2, json.dumps(value, ensure_ascii=False, default=str))
|
||||
if isinstance(value, str) and _NUMBER_PATH.match(value):
|
||||
return (1, tuple(int(part) for part in value.split("-")))
|
||||
return (2, str(value))
|
||||
|
||||
|
||||
def _sorted(rows: list[dict[str, Any]], column: str, desc: bool) -> list[dict[str, Any]]:
|
||||
@@ -343,7 +356,10 @@ def _sorted(rows: list[dict[str, Any]], column: str, desc: bool) -> list[dict[st
|
||||
|
||||
|
||||
#: 돌쌓기 표준경사는 B06 횡단설계가 같은 값으로 기울기를 그림 — 아홉 중 **그림에도 닿는** 하나(브레인).
|
||||
_SLOPE_NOTICE = "고치면 횡단 도면 모양도 함께 바뀜 — B06 횡단설계가 같은 값으로 벽 기울기를 그림"
|
||||
_SLOPE_NOTICE = (
|
||||
"⚠ **B06 횡단설계가 아직 여기서 고친 값을 안 읽음**(원본 값으로 벽 기울기를 그림) — 지금은 고쳐도 횡단 도면이 안 바뀜."
|
||||
" 읽게 되면: 고치면 횡단 도면 모양도 함께 바뀜"
|
||||
)
|
||||
#: 같은 「1:0.3」 이 두 자리에 있음(코덱스 검증 2) — 값은 같으나 **축과 근거가 다름**.
|
||||
#: masonry_slope = 품셈 13-4-4 [주]⑪ 표준경사(직고 · 메/찰 · 성토/절토 축 · 원문표)
|
||||
#: masonry_class.face_slope = 교본 7-3 돌흙막이(구조물 **형식**별 기본값 · 판정이 안 되는 자리의 종전값)
|
||||
@@ -360,6 +376,8 @@ _EXTRA_NOTICE = {
|
||||
|
||||
def notice(kind: str) -> list[str]:
|
||||
"""표 수준 알림 — 화면이 따로 가지면 문구가 두 벌이 됨(브레인 ③)."""
|
||||
if kind in work_items.KINDS:
|
||||
return work_items.notice(kind)
|
||||
return [_SCOPE_NOTICE] + ([_EXTRA_NOTICE[kind]] if kind in _EXTRA_NOTICE else [])
|
||||
|
||||
|
||||
@@ -367,6 +385,8 @@ def spec(kind: str, columns: list[str]) -> dict[str, Any]:
|
||||
"""표 수준 — editable · locked · formula."""
|
||||
if kind == "machine":
|
||||
return machine.spec(columns)
|
||||
if kind in work_items.KINDS:
|
||||
return work_items.spec(columns)
|
||||
if kind in base_tables.SPEC:
|
||||
spec_of = base_tables.SPEC[kind]
|
||||
editable = [c for c in spec_of["editable"] if c in columns]
|
||||
@@ -408,6 +428,8 @@ BASE_PRICE_GROUP, PUMSEM_GROUP = "base_price", "pumsem_basis"
|
||||
|
||||
|
||||
def kind_group(kind: str) -> str:
|
||||
if kind in work_items.KINDS:
|
||||
return work_items.GROUP
|
||||
return PUMSEM_GROUP if kind in base_tables.SPEC else BASE_PRICE_GROUP
|
||||
|
||||
|
||||
@@ -418,14 +440,21 @@ def kind_label(kind: str) -> str:
|
||||
return named.get("name_ko") or kind
|
||||
|
||||
|
||||
def group_label(group: str) -> str:
|
||||
"""상자 이름 — 이름표 `base_price_groups[갈래].name_ko` 에서만(없으면 영문 그대로 · 화면은 `group_label` 을 읽음)."""
|
||||
named = (tables.load_labels().get("base_price_groups") or {}).get(group) or {}
|
||||
return named.get("name_ko") or group
|
||||
|
||||
|
||||
def kinds() -> list[dict[str, Any]]:
|
||||
"""화면이 상자를 세울 목록 — **서버가 냄**(화면이 제 코드에 들면 새 kind 가 조용히 안 뜸).
|
||||
|
||||
갈래 · kind · 한글 이름(이름표) · 줄 수.
|
||||
갈래 · 갈래 한글 이름 · kind · 한글 이름(이름표) · 줄 수.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"group": kind_group(kind),
|
||||
"group_label": group_label(kind_group(kind)),
|
||||
"kind": kind,
|
||||
"label": kind_label(kind),
|
||||
"rows": len(rows(kind)),
|
||||
@@ -439,6 +468,8 @@ def row_label(kind: str, row: dict[str, Any] | None) -> str:
|
||||
return ""
|
||||
if kind == "labor":
|
||||
return str(row.get("occupation_name") or "")
|
||||
if kind in work_items.KINDS:
|
||||
return str(row.get("name") or "")
|
||||
if kind == "machine":
|
||||
return f"{row.get('machine_name') or ''} {row.get('specification') or ''}".strip()
|
||||
if kind == "material":
|
||||
@@ -493,6 +524,18 @@ def public(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {k: v for k, v in row.items() if k not in ("@source", "@axis")}
|
||||
|
||||
|
||||
#: 찾기만 같게 보는 글자 — 원문이 물결표·가운뎃점을 여러 벌로 씀(∼ 26 · ~ 8 · ․ · ㆍ) · 자간 공백(「굴 삭 기」).
|
||||
#: ⚠ **찾기에만** 씀 — 열쇠·칸 값은 원문 그대로(손질하면 갈래 열쇠가 갈림 · 브레인 2026-09-17).
|
||||
_SEARCH_SAME = str.maketrans(
|
||||
{"∼": "~", "~": "~", "〜": "~", "․": "·", "ㆍ": "·", "・": "·", "‧": "·"}
|
||||
)
|
||||
|
||||
|
||||
def fold(text: str) -> str:
|
||||
"""찾기 비교용 — 소문자 · 물결표·가운뎃점 한 벌 · 공백 없앰."""
|
||||
return "".join(str(text or "").lower().translate(_SEARCH_SAME).split())
|
||||
|
||||
|
||||
def table(
|
||||
kind: str,
|
||||
page: int = 1,
|
||||
@@ -500,19 +543,22 @@ def table(
|
||||
q: str = "",
|
||||
sort: str = "",
|
||||
desc: bool = False,
|
||||
filters: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
all_rows = rows(kind)
|
||||
columns = _columns(kind, all_rows)
|
||||
needle = q.strip().lower()
|
||||
needle = fold(q)
|
||||
hits = (
|
||||
[
|
||||
r
|
||||
for r in all_rows
|
||||
if needle in json.dumps(public(r), ensure_ascii=False, default=str).lower()
|
||||
if needle in fold(json.dumps(public(r), ensure_ascii=False, default=str))
|
||||
]
|
||||
if needle
|
||||
else all_rows
|
||||
)
|
||||
if kind in work_items.KINDS: # 서버가 준 거르기(부문·줄 구실) — 고른 값만 옴
|
||||
hits = work_items.apply_filters(kind, hits, filters or {})
|
||||
if sort: # ⚠ 거른 뒤에 세움 — 거꾸로 하면 차례가 깨짐(브레인 ③)
|
||||
if sort not in columns:
|
||||
raise SortError(f"없는 열로 세울 수 없음: {sort}")
|
||||
@@ -528,6 +574,7 @@ def table(
|
||||
**spec(kind, columns),
|
||||
"notice": notice(kind),
|
||||
"source": sources(kind),
|
||||
**(work_items.extra(kind) if kind in work_items.KINDS else {}),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
GET /api/master-data/tree 갈래 → 파일 → 표
|
||||
GET /api/master-data/rows?file=&table=&page=&size=&q= 표 줄(쪽 나누기 · 검색)
|
||||
GET /api/master-data/base-prices 표 목록(갈래·kind·이름·줄 수)
|
||||
GET /api/master-data/base-prices/{kind}?page=&size=&q=&sort=&desc= 한 표(기초단가 다섯 · 품셈 기준 열)
|
||||
GET /api/master-data/base-prices 표 목록(갈래·kind·이름·줄 수 · 공종 축 산림·건설 포함)
|
||||
GET /api/master-data/base-prices/{kind}?page=&size=&q=&sort=&desc=[&거르기=값] 한 표(기초단가 다섯 · 품셈 기준 열 · 공종 축 둘)
|
||||
PUT /api/master-data/base-prices/{kind}/{row_id} {values:{열:값}} → 덮개에만 씀 · null = 되돌리기
|
||||
GET /api/master-data/overrides 고친 것·원본 바뀐 것·주인 없는 것(서버 정렬)
|
||||
⚠ 권한은 등록하는 쪽(`main.py` · 랩탑 서브)이 `dependencies=[verify_session, require_system_admin]` 로 붙임.
|
||||
@@ -13,13 +13,14 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from Z01_MasterData import Z01_MasterData_BasePrices as base_prices
|
||||
from Z01_MasterData import Z01_MasterData_Tables as tables
|
||||
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
|
||||
|
||||
router = APIRouter(prefix="/api/master-data", tags=["Z01 MasterData"])
|
||||
|
||||
@@ -58,14 +59,19 @@ def get_base_price_kinds() -> dict:
|
||||
@router.get("/base-prices/{kind}")
|
||||
def get_base_prices(
|
||||
kind: str,
|
||||
request: Request,
|
||||
page: int = 1,
|
||||
size: int = tables.DEFAULT_PAGE_SIZE,
|
||||
q: str = "",
|
||||
sort: str = "",
|
||||
desc: int = 0,
|
||||
) -> dict:
|
||||
# 거르기 — 표가 준 `filters` 의 key 만 받음(공종 축 부문·줄 구실)
|
||||
picked = {k: v for k, v in request.query_params.items() if k in work_items.FILTER_KEYS}
|
||||
try:
|
||||
return base_prices.table(_kind(kind), page=page, size=size, q=q, sort=sort, desc=bool(desc))
|
||||
return base_prices.table(
|
||||
_kind(kind), page=page, size=size, q=q, sort=sort, desc=bool(desc), filters=picked
|
||||
)
|
||||
except base_prices.SortError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export function columnTitle(column: MasterColumn): string {
|
||||
export function cellText(value: unknown, unit?: string | null): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "boolean") return value ? "예" : "아니오";
|
||||
// 목록 칸(갈래 키 · 딸린 품셈 표) — 괄호·따옴표 없이 쉼표로 · 빈 목록은 빈칸.
|
||||
if (Array.isArray(value)) return value.map((each) => cellText(each, unit)).join(", ");
|
||||
if (typeof value === "number" && unit) {
|
||||
return value.toLocaleString("ko-KR", { maximumFractionDigits: 10 });
|
||||
}
|
||||
@@ -88,18 +90,22 @@ export function outdatedBanner(sources?: TableSource[]): string[] {
|
||||
/** 서버가 주는 표 한 벌 — 갈래·kind·한글 이름·줄 수. 화면은 제 목록을 안 가짐(늘면 저절로 섬). */
|
||||
export interface ServedKind {
|
||||
group: string;
|
||||
/** 갈래 한글 이름(이름표 `base_price_groups`) — 안 오면 group 키가 그대로 떠 빠진 것이 드러남 */
|
||||
group_label?: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
type KindBox = { group: string; label: string; items: ServedKind[] };
|
||||
|
||||
/** 받은 차례 그대로 갈래별로 묶음 — 갈래 차례도 서버가 낸 순서를 따름. */
|
||||
export function groupKinds(kinds: ServedKind[]): { group: string; items: ServedKind[] }[] {
|
||||
const boxes: { group: string; items: ServedKind[] }[] = [];
|
||||
export function groupKinds(kinds: ServedKind[]): KindBox[] {
|
||||
const boxes: KindBox[] = [];
|
||||
for (const kind of kinds) {
|
||||
const box = boxes.find((each) => each.group === kind.group);
|
||||
if (box) box.items.push(kind);
|
||||
else boxes.push({ group: kind.group, items: [kind] });
|
||||
else boxes.push({ group: kind.group, label: kind.group_label ?? kind.group, items: [kind] });
|
||||
}
|
||||
return boxes;
|
||||
}
|
||||
@@ -130,8 +136,26 @@ export function boldSegments(text: string): { text: string; bold: boolean }[] {
|
||||
.filter((part) => part.text !== "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버가 주는 거르기 한 벌(건설품셈 「임도가 쓰는 것」 따위) — 이름·선택지·줄 수·기본값 모두 서버 것.
|
||||
* 어느 줄을 거를지도 서버가 판정함 — 화면은 고른 값을 `key=value` 로 보내기만(2026-09-17 브레인).
|
||||
*/
|
||||
export interface TableFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
options: { value: string; label: string; rows?: number }[];
|
||||
/** 값을 안 보냈을 때 서버가 쓴 선택지 — 화면이 이것을 보여야 보이는 것과 걸린 것이 같음 */
|
||||
default: string;
|
||||
}
|
||||
|
||||
/** 거르기 상자에 보일 값 — 사람이 고른 것 · 안 골랐으면 서버가 이미 건 기본값. */
|
||||
export function filterValue(filter: TableFilter, picked: Record<string, string>): string {
|
||||
return picked[filter.key] ?? filter.default;
|
||||
}
|
||||
|
||||
/** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */
|
||||
export interface TableMeta {
|
||||
filters?: TableFilter[];
|
||||
/** 한 표가 모은 원본마다 한 줄 */
|
||||
source?: TableSource[];
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
cellInfo,
|
||||
clampPage,
|
||||
columnTitle,
|
||||
filterValue,
|
||||
latestOnly,
|
||||
nextSort,
|
||||
noticeLines,
|
||||
@@ -90,7 +92,12 @@ function stateText(info: CellInfo, column: MasterColumn): string {
|
||||
|
||||
export function buildRowsView(): RowsView {
|
||||
let source: RowsSource | null = null;
|
||||
const state = { page: 1, q: "", sort: null as SortOrder | null };
|
||||
const state = {
|
||||
page: 1,
|
||||
q: "",
|
||||
sort: null as SortOrder | null,
|
||||
filters: {} as Record<string, string>,
|
||||
};
|
||||
let showHidden = false;
|
||||
let last: MasterRows | null = null;
|
||||
let selected: { index: number; key: string } | null = null;
|
||||
@@ -137,9 +144,10 @@ export function buildRowsView(): RowsView {
|
||||
const prev = pagerButton("Z01_MasterData_Prev", () => goTo(state.page - 1));
|
||||
const next = pagerButton("Z01_MasterData_Next", () => goTo(state.page + 1));
|
||||
const end = pagerButton("Z01_MasterData_Last", () => goTo(Infinity));
|
||||
const filterBox = el("div", { className: "z01-master__toolbar" });
|
||||
const toolbar = el("div", {
|
||||
className: "z01-master__toolbar",
|
||||
children: [search.root, hiddenLabel],
|
||||
children: [search.root, filterBox, hiddenLabel],
|
||||
});
|
||||
const pager = el("div", {
|
||||
className: "z01-master__pager",
|
||||
@@ -180,6 +188,7 @@ export function buildRowsView(): RowsView {
|
||||
size: PAGE_SIZE,
|
||||
q: state.q,
|
||||
sort: state.sort,
|
||||
filters: state.filters,
|
||||
});
|
||||
} catch (error) {
|
||||
grid.classList.remove("is-loading");
|
||||
@@ -250,6 +259,30 @@ export function buildRowsView(): RowsView {
|
||||
const outdated = outdatedBanner(meta.source);
|
||||
banner.replaceChildren(...outdated.map((text) => el("p", { text: `⚠ ${text}` })));
|
||||
banner.hidden = outdated.length === 0;
|
||||
// 거르기 — 선택지·이름·줄 수·기본값도, 어느 줄을 거를지 판정도 서버 것(화면은 고른 값만 보냄).
|
||||
filterBox.replaceChildren(
|
||||
...(meta.filters ?? []).map(
|
||||
(filter) =>
|
||||
createSelectField({
|
||||
label: filter.label,
|
||||
options: filter.options.map((option) => ({
|
||||
value: option.value,
|
||||
text:
|
||||
option.rows == null
|
||||
? option.label
|
||||
: `${option.label} (${option.rows.toLocaleString("ko-KR")})`,
|
||||
})),
|
||||
value: filterValue(filter, state.filters),
|
||||
onChange: (value) => {
|
||||
state.filters[filter.key] = value;
|
||||
state.page = 1;
|
||||
void load();
|
||||
},
|
||||
}).root,
|
||||
),
|
||||
);
|
||||
// 숨긴 열이 없는 표(17 중 15)에서는 눌러도 아무 일이 없어 체크를 감춤.
|
||||
hiddenLabel.hidden = !meta.columns.some((column) => column.hidden);
|
||||
const columns = shownColumns(meta.columns, showHidden);
|
||||
const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) });
|
||||
const body = el("tbody");
|
||||
@@ -429,6 +462,8 @@ export function buildRowsView(): RowsView {
|
||||
source = picked;
|
||||
state.page = 1;
|
||||
state.sort = null;
|
||||
state.filters = {};
|
||||
filterBox.replaceChildren();
|
||||
title.textContent = picked.title;
|
||||
titleKey.textContent = picked.key;
|
||||
notice.hidden = true;
|
||||
|
||||
@@ -56,7 +56,7 @@ export function buildSidePanel(
|
||||
button.addEventListener("click", () => {
|
||||
activate(button);
|
||||
view.show({
|
||||
title: `${box.group} › ${served.label}`,
|
||||
title: `${box.label} › ${served.label}`,
|
||||
key: `base-prices/${served.kind}`,
|
||||
load: (query) => fetchBasePrices(served.kind, query),
|
||||
save: (rowId, values) => saveBasePrice(served.kind, rowId, values),
|
||||
@@ -64,7 +64,7 @@ export function buildSidePanel(
|
||||
});
|
||||
grid.append(button);
|
||||
}
|
||||
return { group: box.group, grid };
|
||||
return { label: box.label, grid };
|
||||
});
|
||||
const overridesButton = el("button", {
|
||||
className: "z01-master__base z01-master__base--wide",
|
||||
@@ -131,7 +131,7 @@ export function buildSidePanel(
|
||||
}
|
||||
|
||||
panel.append(
|
||||
...boxes.map((box) => section(box.group, box.grid)),
|
||||
...boxes.map((box) => section(box.label, box.grid)),
|
||||
section(L("Z01_MasterData_Tree"), tree),
|
||||
);
|
||||
attachCollapsible(panel);
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Z01 공종 축 — 산림·건설 두 kind 를 기초단가와 **같은 계약**으로(2026-09-17 브레인 · PLAN_공종축 8-4).
|
||||
|
||||
줄 = **품셈 표가 붙은 마디**(`axis_policy.json` `flat_list_filter: pum_tables > 0` — 잎으로 거르면 부모에 붙은 표가 안 뜸) ·
|
||||
갈래(`variant_keys`)마다 한 줄로 폄 — 줄 수 = 편 수(`stats.units`) · 공종 수 = `stats.work_items`.
|
||||
이름 = 뿌리부터 전체 경로(「토공 › 토사깍기 › 기계」 · 건설은 부문부터) — 잎 이름만으론 겹침.
|
||||
계산 규칙 = 표 수준 `rules`(부모 마디 choose_one · sum_steps) + 줄 칸 `rule`·`step_weight`
|
||||
— 빠지면 발파암(절취 0.1 + 깍기 0.9 + 집토 1) 금액이 틀어짐.
|
||||
`@id` = 불변 열쇠(FW-·CW-) — 없는 줄은 목차 코드로 서고 알림에 드러냄.
|
||||
⚠ 읽기만 — 고칠 칸 없음(원문 값 풀기 8-7 뒤). 원본 `work_item_code`(FP-)는 B08·B09 가 씀 — 안 건드림.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from Z01_MasterData import Z01_MasterData_Tables as tables
|
||||
|
||||
GROUP = "work_item"
|
||||
#: kind → 원본 품셈 dataset_id — 공종 축 파일 `dataset_version.dataset_id` 로 가림(파일 이름에 기대지 않음)
|
||||
SOURCE_OF = {
|
||||
"work_item_forest": "pum_forest",
|
||||
"work_item_const": "pum_const",
|
||||
} # 이름표 `work_item_axis` 와 같은 이름
|
||||
FOLDER = tables.RESOURCES / "data_work_item_master"
|
||||
#: 불변 열쇠 칸 — ⚠ 데스크탑 서브 자료가 오면 칸 이름을 맞출 것(여기 한 곳)
|
||||
KEY_FIELD = "work_item_key"
|
||||
PATH_JOIN = " › "
|
||||
_LOCKED = "공종 축 뼈대 — 품셈 원문으로만 바뀜(값 고치기는 원문 값 풀기 뒤)"
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _read(path: str, _mtime_ns: int) -> dict[str, Any]:
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def doc(kind: str) -> tuple[dict[str, Any], str] | None:
|
||||
"""그 품셈의 공종 축 끝 판(문서 · 파일 이름) — 없으면 None(건설은 아직 없음)."""
|
||||
found = []
|
||||
for path in sorted(FOLDER.glob("*work_item_master_*.json")): # 건설은 `const_` 앞꼬리
|
||||
d = _read(str(path), path.stat().st_mtime_ns)
|
||||
if (d.get("dataset_version") or {}).get("dataset_id") == SOURCE_OF[kind]:
|
||||
found.append((d, path.name))
|
||||
return max(found, key=lambda each: each[0].get("effective_date") or "", default=None)
|
||||
|
||||
|
||||
#: 자료가 있는 품셈만 kind 로 섬 — 빈 상자는 이름만 뜨고 안 열림 · 건설은 자료가 오면 서버 재시작 뒤 저절로
|
||||
KINDS = tuple(kind for kind in SOURCE_OF if doc(kind))
|
||||
|
||||
|
||||
def _key(item: dict[str, Any]) -> str:
|
||||
return item.get(KEY_FIELD) or item["work_item_code"]
|
||||
|
||||
|
||||
def _tree(d: dict[str, Any]) -> tuple[list[dict], dict[str, dict], dict[str, list[dict]]]:
|
||||
items = d.get("work_items") or []
|
||||
children: dict[str, list[dict]] = {}
|
||||
for item in items:
|
||||
if item.get("parent_code"):
|
||||
children.setdefault(item["parent_code"], []).append(item)
|
||||
return items, {i["work_item_code"]: i for i in items}, children
|
||||
|
||||
|
||||
def _path(item: dict[str, Any] | None, by: dict[str, dict]) -> str:
|
||||
names = []
|
||||
while item:
|
||||
names.append(item["name"])
|
||||
item = by.get(item.get("parent_code"))
|
||||
return PATH_JOIN.join(reversed(names))
|
||||
|
||||
|
||||
def _shown_path(item: dict[str, Any], by: dict[str, dict]) -> str:
|
||||
"""목록에 보일 경로 — 조각마다 자간 공백 다듬기(「토목부문 › 측 량」→「측량」). 열쇠와 무관."""
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import tidy_spacing
|
||||
|
||||
return PATH_JOIN.join(tidy_spacing(part) for part in _path(item, by).split(PATH_JOIN))
|
||||
|
||||
|
||||
def base_rows(kind: str) -> list[dict[str, Any]]:
|
||||
found = doc(kind)
|
||||
if found is None:
|
||||
return []
|
||||
d, name = found
|
||||
items, by, children = _tree(d)
|
||||
# 계산 규칙 이름 — 이름표 `value_overrides["work_item_master::<방식>"]`(「아래를 단계로 다 더함」) · 없으면 영문
|
||||
named = tables.load_labels().get("value_overrides") or {}
|
||||
linked, roles = linked_keys(), _role_names()
|
||||
rows = []
|
||||
for item in items:
|
||||
if not item.get("tables"):
|
||||
continue
|
||||
parent = by.get(item.get("parent_code")) or {}
|
||||
weight = next(
|
||||
(s["weight"] for s in parent.get("steps") or [] if s["code"] == item["work_item_code"]),
|
||||
None,
|
||||
)
|
||||
key = _key(item)
|
||||
# 갈래마다 한 줄 — 일위대가가 갈래마다 달라 집는 단위가 갈래임(교목굴취 나무높이 9 · 2026-09-17 브레인 ③).
|
||||
# 열쇠 = `공종 열쇠#갈래 번호`(`FW-00105#01`) — **갈래 글자를 열쇠에 담지 않는다.**
|
||||
# 담으면 원문 자간·물결표가 다듬어지는 날 열쇠가 갈린다(2026-09-17 브레인 · 장부가 번호를 준다).
|
||||
for variant in item.get("variants") or [None]:
|
||||
rows.append(
|
||||
{
|
||||
"@id": f"{key}#{variant['variant_key']}" if variant else key,
|
||||
"@key": key, # 공종 열쇠 — 거르기·줄 수는 공종 단위로
|
||||
"@source": name,
|
||||
"work_item_code": item["work_item_code"], # 목차 코드 — 열쇠 아님, 칸으로만
|
||||
"number": item["number"],
|
||||
"name": _shown_path(item, by), # 자간 공백만 다듬음 — 원문 이름은 마스터에
|
||||
"variant": variant["name"] if variant else None, # 원문 그대로
|
||||
"variant_clean": variant["name_clean"] if variant else None, # 다듬은 것
|
||||
"pum_tables": [t["pum_table_id"] for t in item["tables"]],
|
||||
"rule": (named.get(f"work_item_master::{parent.get('parent_mode')}") or {}).get(
|
||||
"name_ko"
|
||||
)
|
||||
or parent.get("parent_mode"),
|
||||
"step_weight": weight,
|
||||
# 「임도가 쓰는 것」 — 연결고리 표가 확정으로 이은 공종(판정은 그 표 · 감추지 않고 표시만)
|
||||
"link": LINKED if key in linked else "",
|
||||
# 줄 구실(총칙·공종) — 이름은 axis_policy.json · 표시 칸(거르기 상자로 고름)
|
||||
"axis_role": roles.get(str(item.get("axis_role") or ""), ""),
|
||||
"effective_date": d.get("pum_edition") or d.get("effective_date"),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
LINK_FOLDER = tables.RESOURCES / "data_work_item_link"
|
||||
LINKED = "연결됨"
|
||||
|
||||
|
||||
def linked_keys() -> frozenset[str]:
|
||||
"""연결고리 표(`work_item_link_*.json` 끝 판 · 코덱스)가 **확정**으로 이은 열쇠(산림·건설).
|
||||
|
||||
⚠ 목록을 코드에 안 둠 — 판정은 그 표 · 미판정은 안 이음(표의 `unconfirmed_action: do_not_link`).
|
||||
"""
|
||||
found = sorted(LINK_FOLDER.glob("work_item_link_*.json")) if LINK_FOLDER.is_dir() else []
|
||||
if not found:
|
||||
return frozenset()
|
||||
links = _read(str(found[-1]), found[-1].stat().st_mtime_ns).get("links") or []
|
||||
return frozenset(
|
||||
key
|
||||
for link in links
|
||||
if link.get("status") == "확정"
|
||||
for key in (link.get("forest_key"), link.get("const_key"))
|
||||
if key
|
||||
)
|
||||
|
||||
|
||||
def rules(kind: str) -> list[dict[str, Any]]:
|
||||
"""부모 마디 계산 규칙 — choose_one(아래 중 하나) · sum_steps(아래를 무게대로 더해야 한 단위)."""
|
||||
found = doc(kind)
|
||||
if found is None:
|
||||
return []
|
||||
items, by, children = _tree(found[0])
|
||||
out = []
|
||||
for item in items:
|
||||
mode = item.get("parent_mode")
|
||||
if not mode:
|
||||
continue
|
||||
rule = {
|
||||
"@id": _key(item),
|
||||
"work_item_code": item["work_item_code"],
|
||||
"name": _shown_path(item, by),
|
||||
"mode": mode,
|
||||
"children": [_key(c) for c in children.get(item["work_item_code"], [])],
|
||||
}
|
||||
if mode == "sum_steps":
|
||||
rule["steps"] = [
|
||||
{"@id": _key(by[s["code"]]), "weight": s["weight"]} for s in item["steps"]
|
||||
]
|
||||
rule["basis"] = item.get("steps_basis")
|
||||
out.append(rule)
|
||||
return out
|
||||
|
||||
|
||||
def spec(columns: list[str]) -> dict[str, Any]:
|
||||
return {"editable": [], "locked": dict.fromkeys(columns, _LOCKED), "formula": {}}
|
||||
|
||||
|
||||
def notice(kind: str) -> list[str]:
|
||||
lines = ["공종 축은 읽기만 — 고칠 칸 없음(품셈 원문 값 풀기 뒤에 열림)"]
|
||||
found = doc(kind)
|
||||
if found and any(KEY_FIELD not in i for i in found[0].get("work_items") or []):
|
||||
lines.append(
|
||||
"불변 열쇠(FW-·CW-) 아직 없음 — 목차 코드(FP-)를 열쇠로 씀 · 품셈 개정으로 번호가 밀리면 딴 줄을 가리킬 수 있음"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
#: 거르기 — **자료에 있는 가름만**(건설 부문 · 줄 구실 · 연결고리 표). 어느 줄을 거를지는 서버 한 곳 ·
|
||||
#: 화면은 고른 값만 보냄 · 연결은 기본 「전부」(감추지 않음 — 「어디에 쓸지 모르니 건설 전체」 · 브레인) ·
|
||||
#: 줄 구실만 기본 「공종」(총칙 기본 감춤 · 서브 2ee6a7ed · 브레인).
|
||||
FILTER_KEYS = ("link", "division", "axis_role")
|
||||
ALL = ""
|
||||
#: 값을 안 보냈을 때 거는 값 — **잣대가 정한다**(`axis_policy.json` 의 `list_default`).
|
||||
#: ⚠ 코드에 박지 않는다 — 총칙 범위는 기관이 정하는 것이라 관리자가 고칠 수 있어야 한다.
|
||||
#: 잣대를 못 읽을 때만 쓰는 대비값(총칙 줄은 기본 감춤 · 「전부」를 고르면 보임 · 2026-09-17 브레인).
|
||||
FALLBACK_DEFAULTS = {"axis_role": "work_item"}
|
||||
|
||||
|
||||
def _facets(kind: str) -> dict[str, dict[str, str]]:
|
||||
found = doc(kind)
|
||||
items = found[0].get("work_items") or [] if found else []
|
||||
linked = linked_keys()
|
||||
return {
|
||||
_key(i): {
|
||||
"link": LINKED if _key(i) in linked else ALL,
|
||||
"division": str(i.get("division") or ""),
|
||||
"axis_role": str(i.get("axis_role") or ""),
|
||||
}
|
||||
for i in items
|
||||
}
|
||||
|
||||
|
||||
def _policy() -> dict[str, Any]:
|
||||
"""줄 구실 잣대 — `axis_policy.json`(데스크탑 서브) 한 곳에서."""
|
||||
path = FOLDER / "axis_policy.json"
|
||||
return _read(str(path), path.stat().st_mtime_ns) if path.is_file() else {}
|
||||
|
||||
|
||||
def _role_names() -> dict[str, str]:
|
||||
"""거르기 상자에 뜰 이름. 목록에서는 「총칙」이 아니라 **공종이 아님**을 드러내는 이름으로 적는다."""
|
||||
roles = _policy().get("roles") or {}
|
||||
return {
|
||||
key: str(role.get("list_label") or role.get("name_ko") or key)
|
||||
for key, role in roles.items()
|
||||
}
|
||||
|
||||
|
||||
def _defaults() -> dict[str, str]:
|
||||
"""안 고른 가름에 걸 값 — 잣대의 `list_default`. 못 읽으면 대비값."""
|
||||
wanted = _policy().get("list_default") or {}
|
||||
key = str(wanted.get("filter") or "")
|
||||
return {key: str(wanted.get("value") or ALL)} if key else dict(FALLBACK_DEFAULTS)
|
||||
|
||||
|
||||
def filters(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""거르기 상자 — 값이 둘 이상 갈리는 가름만 · 기본은 **잣대**(`list_default`)가 정한다 — 그 값이 표에 있을 때만."""
|
||||
facets = _facets(kind)
|
||||
labels = {
|
||||
"link": ("연결", {LINKED: "연결된 것만"}),
|
||||
"division": ("부문", {}),
|
||||
"axis_role": ("줄 구실", _role_names()),
|
||||
}
|
||||
out = []
|
||||
for key in FILTER_KEYS:
|
||||
counts: dict[str, int] = {}
|
||||
for row in rows:
|
||||
value = facets.get(row["@key"], {}).get(key, ALL)
|
||||
counts[value] = counts.get(value, 0) + 1
|
||||
values = [v for v in counts if v != ALL]
|
||||
if len(counts) < 2: # 다 같은 값이면 거를 것이 없음(연결은 「연결됨」 · 빈칸 둘로 갈림)
|
||||
continue
|
||||
label, names = labels[key]
|
||||
default = _defaults().get(key, ALL)
|
||||
out.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": label,
|
||||
"options": [{"value": ALL, "label": "전부", "rows": len(rows)}]
|
||||
+ [{"value": v, "label": names.get(v, v), "rows": counts[v]} for v in values],
|
||||
"default": default if default in values else ALL,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def apply_filters(
|
||||
kind: str, rows: list[dict[str, Any]], picked: dict[str, str]
|
||||
) -> list[dict[str, Any]]:
|
||||
facets = _facets(kind)
|
||||
# 안 보낸 가름은 상자 기본값으로 — 상자에 보이는 값과 걸린 값이 같게(찾기 결과가 아니라 표 전체로 정함)
|
||||
defaults = {f["key"]: f["default"] for f in filters(kind, base_rows(kind))}
|
||||
for key in FILTER_KEYS:
|
||||
value = picked.get(key, defaults.get(key, ALL))
|
||||
if value != ALL:
|
||||
rows = [r for r in rows if facets.get(r["@key"], {}).get(key) == value]
|
||||
return rows
|
||||
|
||||
|
||||
def extra(kind: str) -> dict[str, Any]:
|
||||
"""표 수준 덧붙임 — 계산 규칙 · 줄 수 · 갈래 편 수(쪽 나누기와 무관하게 전체)."""
|
||||
rows = base_rows(kind)
|
||||
return {
|
||||
"filters": filters(kind, rows),
|
||||
"rules": rules(kind),
|
||||
"stats": {
|
||||
"work_items": len({r["@key"] for r in rows}),
|
||||
"units": len(rows),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"""공종 불변 열쇠 **길목** — 목차 코드(`FP-`·`CP-`) → 불변 열쇠(`FW-`·`CW-`) (2026-09-17 PLAN_공종축 8-6 이행).
|
||||
|
||||
코덱스 조사(FP- 참조 630곳 · 77파일)의 결론대로 **경계 두 곳에서만** 부름 — 630곳은 안 고침.
|
||||
B08 인계 출구 `B08_Quantity_Engine_Handoff.build_handoff` 줄마다 `work_item_key` 를 더함
|
||||
B09 인계 입구 `B09_Estimation_BillOfQuantities_Input.parse_handoff` 옛 인계(열쇠 없음)에도 채움
|
||||
`work_item_code` 는 **그대로 남김** — 사람이 보는 목차 번호(화면·로그에 보이는 것이 값짐).
|
||||
|
||||
정본은 공종 마스터 파일의 줄(`work_item_code` ↔ `work_item_key` · 판 `pum_edition`) — 장부를 따로 안 읽음
|
||||
(마스터가 장부로 지은 결과라 두 벌이 안 됨).
|
||||
⚠ `AX-WK-`·`AX-ST-` 는 난수 8자리(명세 2장 ④ · 동등 비교만)라 **이미 불변** — 그대로 열쇠.
|
||||
⚠ 못 찾은 코드는 `None` — 지어내지 않음. 받는 쪽이 목록으로 드러냄(`unkeyed_work_item_codes`).
|
||||
⚠ 한 판 안에서 같은 목차 코드가 두 줄이면(목차 오기) 어느 열쇠인지 못 가름 → `None`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
MASTER_DIR = Path(__file__).resolve().parent.parent / "resources" / "data_work_item_master"
|
||||
#: 우리가 세운 공종·구조물 — 난수 코드가 곧 불변 열쇠
|
||||
_STABLE_CODE = re.compile(r"^AX-(?:WK|ST)-[0-9a-f]{8}$")
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _read(path: str, _mtime_ns: int) -> dict[tuple[str, str], str | None]:
|
||||
doc = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
edition = str(doc.get("pum_edition") or doc.get("effective_date") or "")
|
||||
keys: dict[tuple[str, str], str | None] = {}
|
||||
for item in doc.get("work_items") or []:
|
||||
slot = (edition, str(item.get("work_item_code") or ""))
|
||||
keys[slot] = None if slot in keys else item.get("work_item_key")
|
||||
return keys
|
||||
|
||||
|
||||
def _keys() -> dict[tuple[str, str], str | None]:
|
||||
out: dict[tuple[str, str], str | None] = {}
|
||||
for path in sorted(MASTER_DIR.glob("*work_item_master_*.json")): # 산림 + 건설(`const_`)
|
||||
out.update(_read(str(path), path.stat().st_mtime_ns))
|
||||
return out
|
||||
|
||||
|
||||
def work_item_key(code: str | None, pum_edition: str | None = None) -> str | None:
|
||||
"""목차 코드 → 불변 열쇠. 판을 모르면 코드가 한 판에만 있을 때만 줌."""
|
||||
if not code:
|
||||
return None
|
||||
if _STABLE_CODE.match(code):
|
||||
return code
|
||||
keys = _keys()
|
||||
if pum_edition:
|
||||
return keys.get((pum_edition, code))
|
||||
found = {key for (_, each), key in keys.items() if each == code}
|
||||
return found.pop() if len(found) == 1 else None
|
||||
|
||||
|
||||
def work_item_code_of(key: str | None, pum_edition: str | None = None) -> str | None:
|
||||
"""불변 열쇠 → 그 판의 목차 코드(되짚기). 한 곳에서만 찾아질 때만."""
|
||||
if not key:
|
||||
return None
|
||||
if _STABLE_CODE.match(key):
|
||||
return key
|
||||
found = {
|
||||
code
|
||||
for (edition, code), each in _keys().items()
|
||||
if each == key and (pum_edition is None or edition == pum_edition)
|
||||
}
|
||||
return found.pop() if len(found) == 1 else None
|
||||
|
||||
|
||||
def attach_keys(rows: Iterable[dict[str, Any]]) -> list[str]:
|
||||
"""인계 줄마다 `work_item_key` 를 더함(코드 없는 줄은 `None` — 칸은 늘 있음 · 인계 계약) ·
|
||||
코드는 있는데 열쇠를 못 찾은 코드 목록을 돌려줌."""
|
||||
missing: set[str] = set()
|
||||
for row in rows:
|
||||
code = row.get("work_item_code")
|
||||
row["work_item_key"] = work_item_key(code, row.get("pum_edition"))
|
||||
if code and row["work_item_key"] is None:
|
||||
missing.add(str(code))
|
||||
return sorted(missing)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""연결고리 표(`work_item_link`)를 읽는 **한 자리** — 산림·건설 어느 품셈 품을 쓸지 **표가 정함**
|
||||
(2026-09-17 브레인 · 코덱스 크로스체크 ② 「연결고리 미소비」).
|
||||
|
||||
고시 총칙 7 「본 품셈에 명시되지 않은 품은 타 부문 표준품셈 적용 · 유사 공종은 본 품셈 우선」 —
|
||||
그 규칙은 표의 `policy`(`both_precedence` · `unconfirmed_action`)에 있고 코드는 **읽기만** 함(「산림 우선」을 코드에 안 박음).
|
||||
|
||||
확정 · both → `both_precedence` 쪽 열쇠(지금 표는 forest)
|
||||
확정 · 한쪽만 → 그 열쇠 그대로
|
||||
미판정 → **안 고름** · 사유를 붙여 막음(`unconfirmed_action: do_not_link`)
|
||||
표에 없음 → 제 품셈 그대로(품셈에 명시된 품)
|
||||
⚠ 미판정 행 가운데 열쇠가 빈 행(여러 절 묶음)은 줄에 못 닿음 — `undecided_links()` 로 목록만 드러냄.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
FOLDER = Path(__file__).resolve().parent.parent / "resources" / "data_work_item_link"
|
||||
CONFIRMED = "확정"
|
||||
#: 표 `policy.both_precedence` 낱말 → 그 행의 열쇠 칸
|
||||
_KEY_COLUMN = {"forest": "forest_key", "construction": "const_key"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Choice:
|
||||
"""쓸 열쇠 · 막힌 까닭(있으면 금액을 안 세움)."""
|
||||
|
||||
key: str | None
|
||||
blocked: str = ""
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _read(path: str, _mtime_ns: int) -> dict[str, Any]:
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _doc() -> dict[str, Any]:
|
||||
found = sorted(FOLDER.glob("work_item_link_*.json")) if FOLDER.is_dir() else []
|
||||
return _read(str(found[-1]), found[-1].stat().st_mtime_ns) if found else {}
|
||||
|
||||
|
||||
def choose(key: str | None) -> Choice:
|
||||
"""열쇠 하나(FW-·CW-) → 실제로 쓸 열쇠. 미판정 행에 걸리면 안 고르고 막음."""
|
||||
if not key:
|
||||
return Choice(key)
|
||||
doc = _doc()
|
||||
policy = doc.get("policy") or {}
|
||||
rows = [
|
||||
link
|
||||
for link in doc.get("links") or []
|
||||
if key in (link.get("forest_key"), link.get("const_key"))
|
||||
]
|
||||
for link in rows:
|
||||
if link.get("status") != CONFIRMED:
|
||||
return Choice(
|
||||
None,
|
||||
"연결고리 미판정 — 산림·건설 어느 품을 쓸지 근거가 없어 고르지 않음"
|
||||
f"({link.get('evidence') or '근거 없음'})",
|
||||
)
|
||||
for link in rows:
|
||||
if link.get("branch") == "both":
|
||||
column = _KEY_COLUMN.get(str(policy.get("both_precedence") or ""))
|
||||
chosen = link.get(column) if column else None
|
||||
if not chosen:
|
||||
return Choice(
|
||||
None, "연결고리 표에 둘 다 있는 줄의 우선 품셈이 안 적혔음 — 고르지 않음"
|
||||
)
|
||||
return Choice(chosen)
|
||||
return Choice(key)
|
||||
|
||||
|
||||
def undecided_links() -> list[dict[str, str]]:
|
||||
"""미판정 행 — 열쇠가 비어 줄에 못 닿는 행도 드러냄(조용히 안 버림)."""
|
||||
return [
|
||||
{
|
||||
"branch": str(link.get("branch") or ""),
|
||||
"forest_key": str(link.get("forest_key") or ""),
|
||||
"const_key": str(link.get("const_key") or ""),
|
||||
"evidence": str(link.get("evidence") or ""),
|
||||
}
|
||||
for link in _doc().get("links") or []
|
||||
if link.get("status") != CONFIRMED
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,640 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "work_item_link",
|
||||
"effective_date": "2026-01-01",
|
||||
"note": "산림사업 표준품셈과 건설공사 표준품셈의 연결 밑그림. 확정된 단일 절은 FW-/CW- 불변 열쇠로 잇고, 원문이 건설 절을 밝히지 않거나 구판-현행 대응이 확인되지 않은 행은 열쇠 없이 미판정으로 둔다.",
|
||||
"policy": {
|
||||
"both_precedence": "forest",
|
||||
"fallback": "construction",
|
||||
"applies_to": "branch=both,status=확정",
|
||||
"unconfirmed_action": "do_not_link",
|
||||
"source": "산림사업 표준품셈 총칙 1-1 제7호, 원문 L535",
|
||||
"rule": "산림품셈에 명시된 품은 산림값을 우선하고, 명시되지 않은 품만 건설 등 타 부문 표준품셈으로 보완한다."
|
||||
},
|
||||
"links": [
|
||||
{
|
||||
"forest_key": "",
|
||||
"const_key": "",
|
||||
"branch": "forest_only",
|
||||
"evidence": "산림 제2장 조림·제3장 숲가꾸기·제4장 산림병해충방제·제6장 산림경영계획·제7장 수확; 2026 건설품셈 5부문 45장에서 같은 산림사업 공종표 미확인",
|
||||
"difference_note": "㉮ 근거가 될 절이 아예 없음 — 조경·유지관리의 비슷한 이름을 같은 공종으로 볼 근거가 없음",
|
||||
"status": "미판정"
|
||||
},
|
||||
{
|
||||
"forest_key": "",
|
||||
"const_key": "",
|
||||
"branch": "forest_only",
|
||||
"evidence": "산림 13-13-1 목재틀흙막이; 2026 건설품셈에서 같은 구조·규격별 표 미확인",
|
||||
"difference_note": "㉮ 근거가 될 절이 아예 없음 — 건설품셈 전체에서 '목재틀흙막이'·'목재틀' 절을 찾지 못함",
|
||||
"status": "미판정"
|
||||
},
|
||||
{
|
||||
"forest_key": "",
|
||||
"const_key": "CW-00844",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 13-4 돌쌓기에는 배합 원단위 없음; 건설 건축 9-1-1 모르타르 배합 원문 L6~22; masonry_wet AX-WK-c0842a0d 사용",
|
||||
"difference_note": "1㎥당 1:3 배합은 시멘트 510kg·모래 1.10㎥·보통인부 0.66인",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "",
|
||||
"const_key": "CW-00018",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 1-3-1 재료의 할증에 없는 재료를 건설 공통 1-3-1로 보완; material_surcharge_2026-01-01.json source.supplementary",
|
||||
"difference_note": "산림표가 정본이며 산림표에 없는 재료만 건설표 사용",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00260",
|
||||
"const_key": "CW-00330",
|
||||
"branch": "both",
|
||||
"evidence": "산림 9-7-1 원문 L4814~4828이 건설 공통 8-2-13을 직접 참조하고 토목·산림의 적용값을 비교",
|
||||
"difference_note": "버킷계수 K와 체적환산계수 f는 같지만 작업효율 E는 임도 0.35·토목 0.45(임도가 0.10 낮음), 사이클 타임은 임도 180°·22초와 토목 90°·18초(임도가 4초, 22.2% 김). 임도의 협소한 공간과 추락위험을 반영한 차이",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "",
|
||||
"const_key": "",
|
||||
"branch": "both",
|
||||
"evidence": "산림 9-21 원문 L5510~5527; 건설 공통 3-9-2 원문 L692~703; 현행 계산은 건설표의 1,000㎡당 밑수만 사용",
|
||||
"difference_note": "㉰ 축이 달라 못 이음 — 굴삭기 0.2㎥ 기준 산림은 소밀도별 0.80/1.01/1.22hr, 건설은 굴삭기+그래플 3.76hr/1,000㎡로 소밀도끼리도 2.96hr·4.7배 차이. 품은 산림값을 쓰고 건설에서는 단위 밑수만 빌림",
|
||||
"status": "미판정"
|
||||
},
|
||||
{
|
||||
"forest_key": "",
|
||||
"const_key": "",
|
||||
"branch": "both",
|
||||
"evidence": "산림 13-4-4 원문 L7132 이후; 건설 공통 7-1 돌쌓기; stone_kind_2026-01-01.json 구판 건설표 fallback",
|
||||
"difference_note": "㉰ 축이 달라 못 이음 — 산림은 석재 종류·찰/메·뒷길이별 ㎡당 품. 건설은 3인+1인+굴삭기 작업조의 1일 시공량으로 뒷길이 35/55/75cm에서 메 23/26/28㎡·찰 27/30/34㎡. 표 축·포함범위가 달라 직접 치환 불가",
|
||||
"status": "미판정"
|
||||
},
|
||||
{
|
||||
"forest_key": "",
|
||||
"const_key": "",
|
||||
"branch": "both",
|
||||
"evidence": "산림 10-4·10-9 운반식·적재량; 건설 공통 8-1-6(2) 운반거리·속도·왕복시간; B09 운반 계산에서 부분 결합",
|
||||
"difference_note": "산림은 현장 공종·적재 조건, 건설은 운반 시간 산정층을 제공하므로 어느 한쪽으로 대체하지 않음",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "",
|
||||
"const_key": "",
|
||||
"branch": "both",
|
||||
"evidence": "산림 1-3-2 및 임도 토공 표; 건설 공통 1-3-2 및 토공 관련 표",
|
||||
"difference_note": "㉯ 이름은 닮았으나 내용이 다름 — 토량환산 표의 산림 토질 행에 같은 이름처럼 보이면서 서로 다른 계수가 있어 이름만으로 수치 대응 불가",
|
||||
"status": "미판정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00016",
|
||||
"const_key": "CW-00018",
|
||||
"branch": "both",
|
||||
"evidence": "산림 1-3-1과 건설 공통 1-3-1 재료의 할증; material_surcharge_2026-01-01.json",
|
||||
"difference_note": "같은 재료는 산림값 우선, 산림에 없는 재료만 건설값 사용",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00008",
|
||||
"const_key": "CW-00009",
|
||||
"branch": "both",
|
||||
"evidence": "산림 1-2-2 단위 표준 원문 L573~664; 건설 공통 1-2-2 단위표준 원문 L46~99",
|
||||
"difference_note": "㉱ 추가 대조로 확정 — 설계서 수량·소수자리와 금액 단위표가 일치하며 both에서는 산림표 우선",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "",
|
||||
"const_key": "",
|
||||
"branch": "both",
|
||||
"evidence": "산림 1-2-5는 '노임'으로 시중노임단가 적용만 규정(L768~770); 건설 공통 1-2-5 '인력'은 직종 선정·작업반장·안전인력을 규정(L260~263)",
|
||||
"difference_note": "㉯ 이름은 닮았으나 내용이 다름 — 산림의 노임 단가 규정과 건설의 인력 편성 규정이라 절 대응 불가",
|
||||
"status": "미판정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00401",
|
||||
"const_key": "CW-00267",
|
||||
"branch": "both",
|
||||
"evidence": "산림 12-27-1 지수판 설치 원문 L6731~6742; 건설 공통 6-3-9 지수판 설치 원문 L472~489",
|
||||
"difference_note": "㉱ 추가 대조로 확정 — 두 표 모두 m당 특별인부 0.151인·보통인부 0.116인, PVC 지수판 1.04m·용접봉 0.042kg·철선 0.21kg, 공구손료 3%로 일치",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00009",
|
||||
"const_key": "CW-00010",
|
||||
"branch": "both",
|
||||
"evidence": "산림 1-2-3 토질 원문 L665~750; 건설 공통 1-2-3 토질 원문 L99~185",
|
||||
"difference_note": "㉱ 추가 대조로 확정 — 지반설계, 토질·암 분류와 체적환산계수의 항목·값이 일치하며 both에서는 산림표 우선",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "",
|
||||
"const_key": "",
|
||||
"branch": "both",
|
||||
"evidence": "산림 12-2 표면 마무리 원문 L6157~6165; 건설 공통 6-1-3 표면 마무리 원문 L12~18",
|
||||
"difference_note": "㉰ 축이 달라 못 이음 — 작업 설명과 숫자 0.34인은 같지만 산림은 ㎡당, 건설은 100㎡당이라 동일 면적 환산 시 100배 차이. 원문 단위 불일치가 해소되기 전 연결 금지",
|
||||
"status": "미판정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00013",
|
||||
"const_key": "CW-00014",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 1-2-7 원문 L844가 건설 공통 1-2-7 운반·지게운반을 직접 지시",
|
||||
"difference_note": "산림 운반 계산에서 건설 지게운반 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00013",
|
||||
"const_key": "CW-00338",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 1-2-7 원문 L843~844가 건설 공통 8-2-21 경운기의 적재·적하시간과 속도를 직접 지시",
|
||||
"difference_note": "경운기 운반의 적재·적하시간과 속도를 건설품셈으로 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00078",
|
||||
"const_key": "CW-00352",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 2-2-11 원문 L1720이 건설 공통 8-3-1 [00] 토공기계를 직접 지시",
|
||||
"difference_note": "굴착기 손료를 건설품셈으로 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00078",
|
||||
"const_key": "CW-00359",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 2-2-11 원문 L1721이 건설 공통 8-3-8 [70] 기타기계를 직접 지시",
|
||||
"difference_note": "부착용집게 손료를 건설품셈으로 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00078",
|
||||
"const_key": "CW-00354",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 2-2-11 원문 L1722~1723이 건설 공통 8-3-3 [20] 운반 및 하역기계를 직접 지시",
|
||||
"difference_note": "트럭탑재형·타이어형 크레인 손료를 건설품셈으로 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00144",
|
||||
"const_key": "CW-01225",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 5-22-4 원문 L2740이 건설 유지관리 1-2-5 관목 전정을 직접 지시",
|
||||
"difference_note": "관목 전정 품을 건설품셈으로 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00150",
|
||||
"const_key": "CW-00196",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 5-24-1 원문 L2788이 2025 건설 공통 4-1-2 종자뿜어붙이기를 직접 지시; 2026판 4-1-3",
|
||||
"difference_note": "판 개정으로 절 번호가 4-1-2에서 4-1-3으로 이동",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00150",
|
||||
"const_key": "CW-01228",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 5-24-1 원문 L2790이 건설 유지관리 1-2-8 인력관수를 직접 지시",
|
||||
"difference_note": "물주기 필요 시 인력관수 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00150",
|
||||
"const_key": "CW-01229",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 5-24-1 원문 L2790이 건설 유지관리 1-2-9 살수차관수를 직접 지시",
|
||||
"difference_note": "물주기 필요 시 살수차관수 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00152",
|
||||
"const_key": "CW-00198",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 5-25 원문 L2868이 2025 건설 공통 4-1-4 거적덮기를 직접 지시; 2026판 4-1-5",
|
||||
"difference_note": "판 개정으로 절 번호가 4-1-4에서 4-1-5로 이동",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00161",
|
||||
"const_key": "CW-00320",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 5-29 원문 L2950이 건설 공통 8-2-3 굴착기를 직접 지시",
|
||||
"difference_note": "굴착기 운전 품을 건설품셈으로 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00258",
|
||||
"const_key": "CW-00151",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-6-1 원문 L4795가 건설 공통 3-3-6 대규모발파 TYPE-Ⅵ [주]⑦·⑧을 직접 지시",
|
||||
"difference_note": "기계소할의 발파 관련 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00261",
|
||||
"const_key": "CW-01325",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-7-2 원문 L4842가 건설 유지관리 3-1-1 콘크리트구조물 헐기(인력)를 직접 지시",
|
||||
"difference_note": "인력 헐기 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00261",
|
||||
"const_key": "CW-01326",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-7-2 원문 L4842가 건설 유지관리 3-1-2 콘크리트구조물 헐기(기계)를 직접 지시",
|
||||
"difference_note": "기계 헐기 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00263",
|
||||
"const_key": "CW-00330",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-8-1 원문 L4871이 건설 공통 8-2-13 대형브레이커를 직접 지시",
|
||||
"difference_note": "철골재 철거의 대형브레이커 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00263",
|
||||
"const_key": "CW-01327",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-8-1 원문 L4871이 건설 유지관리 3-1-3 철골재 철거(인력)를 직접 지시",
|
||||
"difference_note": "인력 철거 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00263",
|
||||
"const_key": "CW-01328",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-8-1 원문 L4871이 건설 유지관리 3-1-4 철골재 철거(기계)를 직접 지시",
|
||||
"difference_note": "기계 철거 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00264",
|
||||
"const_key": "CW-01327",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-8-2 원문 L4890이 건설 유지관리 3-1-3 철골재 철거(인력)를 직접 지시",
|
||||
"difference_note": "인력 철거 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00264",
|
||||
"const_key": "CW-01328",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-8-2 원문 L4890이 건설 유지관리 3-1-4 철골재 철거(기계)를 직접 지시",
|
||||
"difference_note": "기계 철거 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00265",
|
||||
"const_key": "CW-01329",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-9 원문 L4903이 건설 유지관리 3-1-5 석축 헐기(인력)를 직접 지시",
|
||||
"difference_note": "석축 헐기 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00267",
|
||||
"const_key": "CW-00330",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-10-1 원문 L4919가 건설 공통 8-2-13 대형브레이커를 직접 지시",
|
||||
"difference_note": "브레이커 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00268",
|
||||
"const_key": "CW-00330",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-10-2 원문 L4938이 건설 공통 8-2-13 대형브레이커를 직접 지시",
|
||||
"difference_note": "브레이커 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00270",
|
||||
"const_key": "CW-00416",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-11-1 원문 L4957이 건설 토목 1-6-6 포장 절단을 직접 지시",
|
||||
"difference_note": "포장 절단 품을 건설품셈으로 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00298",
|
||||
"const_key": "CW-00320",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-15-1 원문 L5286이 건설 공통 8-2-3 굴착기를 직접 지시",
|
||||
"difference_note": "굴착기 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00299",
|
||||
"const_key": "CW-00318",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-15-2 원문 L5308이 건설 공통 8-2-1 불도저를 직접 지시",
|
||||
"difference_note": "불도저 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00302",
|
||||
"const_key": "CW-00320",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-16-2 원문 L5340이 건설 공통 8-2-3 굴착기를 직접 지시",
|
||||
"difference_note": "굴착기 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00302",
|
||||
"const_key": "CW-00326",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-16-2 원문 L5340이 건설 공통 8-2-9 롤러를 직접 지시",
|
||||
"difference_note": "롤러 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00305",
|
||||
"const_key": "CW-00332",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-17-1 원문 L5372가 건설 공통 8-2-15 법면다짐기를 직접 지시",
|
||||
"difference_note": "법면다짐기 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00306",
|
||||
"const_key": "CW-00318",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-17-2 원문 L5391이 건설 공통 8-2-1 불도저를 직접 지시",
|
||||
"difference_note": "불도저 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00307",
|
||||
"const_key": "CW-00318",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-18 원문 L5403이 건설 공통 8-2-1 불도저를 직접 지시",
|
||||
"difference_note": "불도저 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00310",
|
||||
"const_key": "CW-00165",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-19-2 원문 L5448이 건설 공통 3-5-1 절토면 고르기를 직접 지시",
|
||||
"difference_note": "절토면 고르기 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00310",
|
||||
"const_key": "CW-00168",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-19-2 원문 L5448이 건설 공통 3-6-1 성토면 고르기를 직접 지시",
|
||||
"difference_note": "성토면 고르기 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00311",
|
||||
"const_key": "CW-00165",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-19-3 원문 L5465가 건설 공통 3-5-1 절토면 고르기를 직접 지시",
|
||||
"difference_note": "절토면 고르기 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00311",
|
||||
"const_key": "CW-00168",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-19-3 원문 L5465가 건설 공통 3-6-1 성토면 고르기를 직접 지시",
|
||||
"difference_note": "성토면 고르기 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00313",
|
||||
"const_key": "CW-00320",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-20-1 원문 L5499가 건설 공통 8-2-3 굴착기를 직접 지시",
|
||||
"difference_note": "굴착기 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00316",
|
||||
"const_key": "CW-00320",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-22 원문 L5545가 건설 공통 8-2-3 굴착기를 직접 지시",
|
||||
"difference_note": "굴착기 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00344",
|
||||
"const_key": "CW-00318",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 10-11 원문 L5920이 건설 공통 8-2-1 불도저를 직접 지시",
|
||||
"difference_note": "불도저 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00356",
|
||||
"const_key": "CW-00324",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 11-4 원문 L6080이 건설 공통 8-2-7 모터그레이더를 직접 지시",
|
||||
"difference_note": "모터그레이더 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00373",
|
||||
"const_key": "CW-00244",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 12-9-2 원문 L6325가 건설 공통 6-1-1 레디믹스트콘크리트 타설을 직접 지시",
|
||||
"difference_note": "레디믹스트콘크리트 타설 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00377",
|
||||
"const_key": "CW-00538",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 12-11-1 원문 L6374가 건설 토목 6-6 원심력 철근콘크리트관 부설을 직접 지시",
|
||||
"difference_note": "원심력 철근콘크리트관 부설 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00377",
|
||||
"const_key": "CW-00539",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 12-11-1 원문 L6374가 건설 토목 6-6-1 소켓관 접합을 직접 지시",
|
||||
"difference_note": "소켓관 접합 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00429",
|
||||
"const_key": "CW-00320",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 13-2-3 원문 L7019이 건설 공통 8-2-3 굴착기를 직접 지시",
|
||||
"difference_note": "굴착기 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00445",
|
||||
"const_key": "CW-00320",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 13-6-1 원문 L7306이 건설 공통 8-2-3 굴착기를 직접 지시",
|
||||
"difference_note": "굴착기 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00446",
|
||||
"const_key": "CW-00320",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 13-6-2 원문 L7333이 건설 공통 8-2-3 굴착기를 직접 지시",
|
||||
"difference_note": "굴착기 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00447",
|
||||
"const_key": "CW-00320",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 13-6-3 원문 L7359이 건설 공통 8-2-3 굴착기를 직접 지시",
|
||||
"difference_note": "굴착기 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00449",
|
||||
"const_key": "CW-00320",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 13-7-1 원문 L7384이 건설 공통 8-2-3 굴착기를 직접 지시",
|
||||
"difference_note": "굴착기 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00450",
|
||||
"const_key": "CW-00320",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 13-7-2 원문 L7407이 건설 공통 8-2-3 굴착기를 직접 지시",
|
||||
"difference_note": "굴착기 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00477",
|
||||
"const_key": "CW-00320",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 14-2 원문 L7820이 건설 공통 8-2-3 굴착기를 직접 지시",
|
||||
"difference_note": "굴착기 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00455",
|
||||
"const_key": "CW-00339",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 13-10-2 원문 L7511이 건설 공통 8-2-22 디젤파일해머를 직접 지시",
|
||||
"difference_note": "디젤파일해머 운전 품을 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "",
|
||||
"const_key": "",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 2-2-9 원문 L1702; 건설 공통 8-3 기계손료표 확인",
|
||||
"difference_note": "㉮ 건설품셈에 그 규격이 안 실림 — 산림은 1톤 방제차량을 지시하지만 건설 8-3 손료표는 덤프트럭 2.5톤 이상만 수록한다. 8-1-2 [주]④의 개별 산정 대상이며 단일 CW 열쇠로 연결하지 않음",
|
||||
"status": "미판정"
|
||||
},
|
||||
{
|
||||
"forest_key": "",
|
||||
"const_key": "",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-7-1 원문 L4810",
|
||||
"difference_note": "㉮ 원문이 절을 안 밝힘 — 건설표준품셈 토목을 적용하라고만 하고 절 번호를 밝히지 않음",
|
||||
"status": "미판정"
|
||||
},
|
||||
{
|
||||
"forest_key": "",
|
||||
"const_key": "",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 9-12-1 원문 L4988",
|
||||
"difference_note": "㉱ 구판-현행 절 대응 미확인 — 2018 건설 토목 3-1-3을 밝히지만 현행 2026판 대응 절을 원문만으로 확정할 수 없음",
|
||||
"status": "미판정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00070",
|
||||
"const_key": "CW-00314",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 2-2-3 원문 L1645가 세부 손료에 건설 공통 8-1-5 기계경비 용어와 정의를 준용; 건설 합본 L5324",
|
||||
"difference_note": "상각비율·정비비율·관리비율 정의를 건설품셈으로 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00070",
|
||||
"const_key": "CW-00315",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 2-2-3 원문 L1645가 세부 손료에 건설 공통 8-1-6 기계경비 적산요령을 준용; 건설 합본 L5324 이후",
|
||||
"difference_note": "시간당 손료계수 산정과 1일 운전시간 원칙을 건설품셈으로 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00085",
|
||||
"const_key": "CW-00320",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 3-4-2 원문 L1796이 굴착기 0.2㎥ 시간당 작업량을 건설 공통 8-2-3에 따라 산출하도록 지시; 건설 합본 L5446",
|
||||
"difference_note": "굴착기 작업능력 Q식을 건설품셈으로 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00085",
|
||||
"const_key": "CW-00352",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 3-4-2 원문 L1796이 굴착기 0.2㎥ 시간당 경비를 건설 공통 8-3-1에 따라 산출하도록 지시",
|
||||
"difference_note": "굴착기 0.2㎥ 손료계수를 건설품셈으로 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00085",
|
||||
"const_key": "CW-00363",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 3-4-2 원문 L1796이 굴착기 0.2㎥ 시간당 경비를 건설 공통 8-4-1에 따라 산출하도록 지시",
|
||||
"difference_note": "굴착기 0.2㎥ 운전경비를 건설품셈으로 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00124",
|
||||
"const_key": "CW-00158",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 5-12 원문 L2504가 성토면 흙다짐 필요 시 건설 공통 3-4-3 흙 다지기를 적용하도록 지시; 건설 합본 L2331",
|
||||
"difference_note": "콤팩터·래머·인력 흙다지기 품을 건설품셈으로 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00130",
|
||||
"const_key": "CW-00014",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 5-16-2 원문 L2594가 200m 초과 떼 운반에 건설 공통 1-2-7 제3호 지게운반을 적용하도록 지시; 건설 합본 L453",
|
||||
"difference_note": "200m 초과 지게운반의 운반량·속도·시간을 건설품셈으로 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00339",
|
||||
"const_key": "CW-00325",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 10-9 원문 L5857이 4륜구동 1톤 덤프트럭 작업량에 건설 공통 8-2-8을 적용하도록 지시; 건설 합본 L5648",
|
||||
"difference_note": "덤프트럭 작업량 Q식을 건설품셈으로 보완",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00339",
|
||||
"const_key": "CW-00352",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 10-9 원문 L5857이 손료 산정에 건설 공통 8-3-1의 2.5톤 덤프트럭을 적용하도록 지시",
|
||||
"difference_note": "2.5톤 덤프트럭 손료계수를 대용",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00339",
|
||||
"const_key": "CW-00363",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 10-9 원문 L5857이 운전경비 산정에 건설 공통 8-4-1의 2.5톤 덤프트럭을 적용하도록 지시",
|
||||
"difference_note": "2.5톤 덤프트럭 운전경비와 주연료 80%를 적용",
|
||||
"status": "확정"
|
||||
},
|
||||
{
|
||||
"forest_key": "FW-00340",
|
||||
"const_key": "CW-00014",
|
||||
"branch": "const_only",
|
||||
"evidence": "산림 10-9-1 원문 L5871이 표에 규격이 없는 재료에 건설 공통 1-2-7 제7호 화물자동차 적재량을 적용하도록 지시; 건설 합본 L489",
|
||||
"difference_note": "산림표에 없는 재료의 1회 표준 적재량을 건설품셈으로 보완",
|
||||
"status": "확정"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "data_work_item_master_manifest",
|
||||
"generated_at": "2026-09-15T05:49:19+09:00",
|
||||
"generated_at": "2026-09-17T18:52:20+09:00",
|
||||
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
|
||||
"source": {
|
||||
"dataset_id": "pum_forest",
|
||||
@@ -12,8 +12,8 @@
|
||||
"files": [
|
||||
{
|
||||
"file": "work_item_master_2026-01-01.json",
|
||||
"sha256": "2a0049e73fe48e44c79dda354ae13cf850a61227acdc22645cb94d52e89bbb2b",
|
||||
"size_bytes": 839589
|
||||
"sha256": "546624601feaf9e9c7d757d6013b7c9bc3bc973817fb42ded70f3b2306b6778d",
|
||||
"size_bytes": 924964
|
||||
},
|
||||
{
|
||||
"file": "form_undetermined_2026-01-01.json",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "data_work_item_master_manifest",
|
||||
"generated_at": "2026-09-17T18:52:53+09:00",
|
||||
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
|
||||
"source": {
|
||||
"dataset_id": "pum_const",
|
||||
"effective_date": "2026-01-01",
|
||||
"sha256": "dbc46ef1316b83ed710f25bc402dcecc4a979bbfc4a6641a0554252bca326ca7",
|
||||
"file": "pum_const_2026.json"
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"file": "const_work_item_master_2026-01-01.json",
|
||||
"sha256": "e8e3bc48c2563246b7a6fd4b5bc5129fe00c5f112fa190d78119912beddf40ff",
|
||||
"size_bytes": 3839500
|
||||
},
|
||||
{
|
||||
"file": "const_form_undetermined_2026-01-01.json",
|
||||
"sha256": "dbb4f245cb9aee71325ab73c1325ee5f06ff19e0e7527de2053d08a7eb16282e",
|
||||
"size_bytes": 486761
|
||||
},
|
||||
{
|
||||
"file": "const_basis_missing_2026-01-01.json",
|
||||
"sha256": "0ae14342e37ba5f394c36cc4dd8cea1b505b13d6520e0a12979c21676d6ffcdb",
|
||||
"size_bytes": 74228
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "work_item_axis_policy",
|
||||
"effective_date": "2026-01-01",
|
||||
"note": "공종 축의 줄 구실(axis_role) 잣대. 코드에 박지 않고 여기 둔다 — 기관이 정하는 범위라 시스템관리자가 고칠 값이다.",
|
||||
"policy": {
|
||||
"never_delete": true,
|
||||
"why": "표 없는 마디 가운데 상당수가 `parent_mode`(계산 규칙)를 지니고, 총칙 장에는 원가가 실제로 읽는 값표가 붙어 있다. 지우면 계산 규칙과 값이 함께 사라진다(2026-09-17 산림에서 확인 · 브레인 승인).",
|
||||
"flat_list_filter": "pum_tables > 0",
|
||||
"flat_list_why": "「잎」으로 거르면 부모 마디에 붙은 표가 통째로 안 뜬다(산림 5건)."
|
||||
},
|
||||
"roles": {
|
||||
"work_item": {
|
||||
"name_ko": "공종",
|
||||
"rule": "품셈 표가 붙은 줄",
|
||||
"keep": true,
|
||||
"list_label": "공종",
|
||||
"hidden_by_default": false
|
||||
},
|
||||
"group": {
|
||||
"name_ko": "묶는 마디",
|
||||
"rule": "표는 없고 아래를 묶는 줄",
|
||||
"keep": true,
|
||||
"why": "`parent_mode`(choose_one·sum_steps)가 여기 붙는다 — 계산 규칙을 지닌 줄이다.",
|
||||
"list_label": "묶는 마디",
|
||||
"hidden_by_default": true
|
||||
},
|
||||
"general_provision": {
|
||||
"name_ko": "총칙",
|
||||
"rule": "`general_provision` 뿌리 아래의 줄",
|
||||
"keep": true,
|
||||
"why": "문서 구역이나 값표가 붙어 원가가 읽는다.",
|
||||
"list_label": "공종 아님 · 규정·기준",
|
||||
"hidden_by_default": true
|
||||
},
|
||||
"empty": {
|
||||
"name_ko": "빈 잎",
|
||||
"rule": "표도 아래도 없는 잎",
|
||||
"keep": true,
|
||||
"why": "원문에 표가 안 붙은 자리. 지우면 「있었는지 없었는지」가 안 남는다.",
|
||||
"list_label": "빈 잎",
|
||||
"hidden_by_default": true
|
||||
}
|
||||
},
|
||||
"decision_order": [
|
||||
"general_provision",
|
||||
"work_item",
|
||||
"group",
|
||||
"empty"
|
||||
],
|
||||
"axes": {
|
||||
"forest": {
|
||||
"name_ko": "산림 공종",
|
||||
"settled": true,
|
||||
"source": "resources/data_cost_input_value/pum_forest_2026.json",
|
||||
"toc_source": "목차표 F0001 — 원문이 목차를 표로 싣는다",
|
||||
"code_prefix": "FP",
|
||||
"key_prefix": "FW",
|
||||
"general_provision_roots": [
|
||||
"FP-01",
|
||||
"FP-02"
|
||||
],
|
||||
"general_provision_basis": "FP-01 적용기준 51줄 · FP-02 소요재료 및 기계손료 27줄. ⚠ 지우면 안 됨 — `B09_Estimation_Consumables.py` 가 FP-02-01-01·FP-02-01-08(연료 F0042·F0043·F0058)과 FP-02-02-01/02/04/05/06(손료계수 F0064~F0069)을 읽는다.",
|
||||
"counts": {
|
||||
"rows": 477,
|
||||
"work_item": 304,
|
||||
"group": 82,
|
||||
"general_provision": 78,
|
||||
"empty": 13
|
||||
},
|
||||
"known_defects": [
|
||||
"갈래 문자열이 그대로 줄 열쇠가 됨(`@id` = `FW-00105#1.1∼1.5`) — 품셈 원문이 물결표를 두 가지로 쓰고(∼ U+223C · ~ U+FF5E) 자간 공백만 다른 짝도 있어, 문자열이 손질되면 열쇠가 갈린다. ⬜ 갈래도 장부에 올릴지 판정 대기(2026-09-17 데스크탑 서브)."
|
||||
]
|
||||
},
|
||||
"const": {
|
||||
"name_ko": "건설 공종",
|
||||
"settled": true,
|
||||
"source": "resources/data_cost_input_value/pum_const_2026.json",
|
||||
"toc_source": "합본 원문 머리 「목 차」 글줄 — 목차표가 없어 랩탑 메인이 글에서 읽음(부문 5 · 장 45 · 1,367줄)",
|
||||
"code_prefix": "CP",
|
||||
"key_prefix": "CW",
|
||||
"code_shape": "CP-<부문2>-<장2>-<절…>",
|
||||
"code_shape_why": "⚠ **부문마다 장 번호가 1부터 다시 시작한다** — 공통 1장은 적용기준, 토목 1장은 도로포장공사다. 부문 자리가 없으면 부문이 다른 공종끼리 덮는다(산림에서 목차 번호 겹침 둘이 실제로 덮고 있었다).",
|
||||
"general_provision_roots": [
|
||||
"01_공통부문/제1장_적용기준.md",
|
||||
"01_공통부문/제8장_건설기계.md"
|
||||
],
|
||||
"general_provision_basis": "공통부문 제1장 적용기준(`CP-01-01`) 줄 37 · 표 46 — 산림 FP-01 과 짝. 공통부문 제8장 건설기계(`CP-01-08`) 줄 75 · 표 351 — 산림 FP-02(소요재료 및 기계손료)와 짝. 시공능력·손료 파라미터라 값을 정하는 자리다(C0426·C0427 은 이미 기초데이터로 꺼냈다). 2026-09-17 안티그래비티 조사 · 브레인 확정.",
|
||||
"general_provision_pending": [],
|
||||
"known_defects": [
|
||||
"형태 미판정 881 / 2,192 — B09 표 읽기가 산림 표로만 검증됨",
|
||||
"밑수 미확보 447 — 곱하면 안 되는 줄이라 받는 쪽이 가려야 함",
|
||||
"갈래 키 비어 있음 — 랩탑 메인이 뒤로 미룸"
|
||||
],
|
||||
"roots_are": "**장 파일 이름**(「01_공통부문/제1장_적용기준.md」). 뽑는 코드가 목차 코드(`CP-01-01`)로 바꿔 준다 — 관리자가 부문 번호를 셀 필요가 없다.",
|
||||
"counts": {
|
||||
"rows": 1367,
|
||||
"work_item": 997,
|
||||
"group": 270,
|
||||
"general_provision": 0,
|
||||
"empty": 100,
|
||||
"note": "총칙 표시 전 셈(랩탑 메인 첫 산출). 뿌리를 붙이면 `general_provision` 으로 옮겨 간다 — 줄이 줄지는 않는다."
|
||||
},
|
||||
"settled_note": "유지관리부문 제1장 「공 통」(`CP-05-01`) 표 40 은 **공종**으로 둔다 — 총칙이 아니다(2026-09-17 안티그래비티 조사 · 브레인 확정)."
|
||||
}
|
||||
},
|
||||
"list_default": {
|
||||
"filter": "axis_role",
|
||||
"value": "work_item",
|
||||
"why": "목록 기본은 **공종만**(산림 306 · 건설 983). 총칙 줄은 표가 붙어 있어 목록에 섞여 뜨는데, 공종이 아니라 규정·기준이다. 상자를 따로 만들지 않고 **기본 감춤 + 고르면 보임**으로 간다 (2026-09-17 브레인). ⚠ 지운 것이 아니라 감춘 것이다."
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -8,8 +8,8 @@ sources:
|
||||
- doc: 산림사업 표준품셈 (산림청고시 제2025-82호)
|
||||
version: 2026-01-01 시행
|
||||
loc: original/행정규칙/임도 품셈 적용기준 (현 산림사업 표준품셈)/
|
||||
status: verified
|
||||
last_updated: 2026-08-15
|
||||
status: draft
|
||||
last_updated: 2026-09-17
|
||||
---
|
||||
|
||||
관련 문서: [품셈_적용기준](품셈_적용기준.md) · [수량산출_일반](수량산출_일반.md) · [기계경비_산정](../05_원가정보/기계경비_산정.md) · [원가_기초데이터_원천](../05_원가정보/원가_기초데이터_원천.md)
|
||||
@@ -43,14 +43,81 @@ last_updated: 2026-08-15
|
||||
| 유지보수 (기존 임도 보수) | [05_유지관리부문](../../../original/원가계산/건설공사_표준품셈/05_유지관리부문/) 4장 | 낮음 | 해체·보수 품 ('22 신설 다수) |
|
||||
| (참고) 건축·기계설비 부문 | 03_건축 11장 · 04_기계설비 13장 | 낮음 | 임도 통상 미사용 — 특수 부대시설 시 참조 |
|
||||
|
||||
## 3. 적용 시 주의
|
||||
## 3. 두 품셈 연결고리 (2026-09-17 밑그림)
|
||||
|
||||
### 3.1 판정 원칙
|
||||
|
||||
- 산림품셈 총칙 1-1 제7호는 `본 품셈에 명시되지 않은 품`에 타 부문 표준품셈을 적용하고, 타 부문과 유사한 공종은 산림품셈을 우선하도록 한다(산림 원문 L535). 따라서 이름이 같거나 비슷하다는 이유만으로 자동 연결하지 않는다.
|
||||
- 아래 **확정**은 산림 원문의 직접 지시 또는 현행 데이터·계산의 출처 기록이 있는 것만 뜻한다. 같은 기능처럼 보여도 절 대 절 대응 근거가 없으면 **미판정**이다.
|
||||
- 이 표는 연결의 존재를 기록한 것이며, 한쪽 품을 다른 쪽 품으로 통째로 바꿀 수 있다는 뜻이 아니다.
|
||||
- 기계 판독용 연결은 `resources/data_work_item_link/work_item_link_2026-01-01.json`에 둔다. 단일 절로 확정된 연결만 FW-/CW- 불변 열쇠를 붙이고, 여러 절을 묶은 행과 미판정 행에는 대표 열쇠를 임의로 붙이지 않는다.
|
||||
- 건설품셈 절 번호는 공통·토목·건축·기계설비·유지관리 **부문마다 1장부터 다시 시작**한다. 따라서 `8-1` 같은 번호만으로 CW- 열쇠를 찾지 않고 부문과 전체 경로를 함께 확인한다.
|
||||
|
||||
### 3.2 산림에만 확인된 갈래
|
||||
|
||||
| 산림품셈 범위 | 건설품셈 대응 | 판정·근거 |
|
||||
|---|---|---|
|
||||
| 제2장 조림·제3장 숲가꾸기·제4장 산림병해충방제·제6장 산림경영계획·제7장 수확 | 미판정 | 2026 건설품셈 5부문 45장에 같은 산림사업 공종표는 확인되지 않음. 비슷한 이름의 조경·유지관리 품과 자동 연결하지 않음 |
|
||||
| 13-13-1 목재틀흙막이 | 미판정 | 산림 원문에 구조·규격별 품이 있으나 2026 건설품셈에서 같은 표를 확인하지 못함 |
|
||||
| 10-9-1 5t 미만 운반장비의 표준적재량(산악지 사방공사) | 미판정 | 산림 원문 고유 조건의 표. 건설 운반표와 절 대 절 대응 근거 없음 |
|
||||
|
||||
### 3.3 산림에 값이 없어 건설품셈으로 잇는 갈래
|
||||
|
||||
| 산림 공종·쓰임 | 건설품셈 근거 | 연결 상태 |
|
||||
|---|---|---|
|
||||
| 전 공종의 건설기계 손료·운전경비 | 공통 8-1 적용기준, 8-3 기계손료, 8-4 운전경비 산정 | **확정·사용 중** — 기계 기초값·손료·운전경비 층. 산림 원문도 굴삭기·크레인 등에서 건설품셈을 직접 지시(2-2-11, L1720~1723) |
|
||||
| 모르타르 1:3 배합 | 건축 9-1-1 모르타르 배합(시멘트 510kg, 모래 1.10㎥, 보통인부 0.66인/㎥) | **확정·사용 중** — 산림 13-4 돌쌓기에는 배합 원단위가 없어 `masonry_wet`의 `AX-WK-c0842a0d`가 사용 |
|
||||
| 재료 할증 중 산림 1-3-1에 없는 재료 | 공통 1-3-1 재료의 할증 | **확정·사용 중** — 산림표가 정본이고 누락 재료만 건설표가 보완함 |
|
||||
| 12-9-2 산마루 측구의 레미콘 타설 | 공통 6-1-1 레디믹스트콘크리트 타설 | **확정·원문 직접 지시** — 산림 원문 L6325 |
|
||||
| 12-11-1 VR관 소켓접합 | 토목 6-6, 6-6-1 철근콘크리트관 부설 | **확정·원문 직접 지시** — 산림 원문 L6374 |
|
||||
| 9-6~9-10 구조물 철거·깨기 | 공통 8-2-13 브레이커, 유지관리 3-1-1~5 철거 | **확정·원문 직접 지시** — 산림 원문 L4795, L4814~4938 |
|
||||
| 9-11-1 포장 절단 | 토목 1-6-6 포장 절단 | **확정·원문 직접 지시** — 산림 원문 L4957 |
|
||||
| 13-10-2 강재·콘크리트 말뚝박기 | 공통 8-2-22 디젤파일해머 | **확정·원문 직접 지시** — 산림 원문 L7511. 목재말뚝 품 자체와 혼용하지 않음 |
|
||||
|
||||
그 밖에 산림 원문이 직접 지시한 보완은 지게·경운기 운반(1-2-7, L844), 기계식 비탈면 마무리(9-19-2·3, L5448~5465), 노상 다짐·정지·층따기(9-16-2~9-18, L5340~5403), 쇄석·혼합석 부설의 모터그레이더(11-4, L6080)이다. 각각 지시된 기계 또는 세부 품만 잇고 공종 전체를 치환하지 않는다.
|
||||
|
||||
### 3.4 둘 다 있으나 그대로 잇지 못하는 갈래
|
||||
|
||||
| 주제 | 산림품셈 | 건설품셈 | 판정 |
|
||||
|---|---|---|---|
|
||||
| 뿌리제거 | 9-21: 굴삭기 0.2㎥ 기준 소밀도별 0.80/1.01/1.22hr | 공통 3-9-2: 1,000㎡당 굴삭기+그래플 0.2㎥ 3.76hr | **값·구성 다름** — 현행 계산은 건설표에서 `1,000㎡당` 밑수만 빌리고 품은 산림표를 씀. 0.80과 3.76은 4.7배 차이이므로 절 자동 대응은 미판정 |
|
||||
| 무근콘크리트 깨기 | 9-7-1: K=0.55, f=0.67, E=0.35, 180°·22초 | 공통 8-2-13: 산림 원문이 비교한 토목값 E=0.45, 90°·18초 | **확정 연결·값 다름** — K·f는 같고, 임도는 협소한 작업공간·추락위험 때문에 E가 0.10 낮고 사이클이 4초(22.2%) 김(산림 원문 L4814~4828). 건설값으로 치환하지 않음 |
|
||||
| 돌쌓기 | 13-4-4: 석재 종류·찰/메·뒷길이별 ㎡당 품 | 공통 7-1: 작업조 1일 시공량(뒷길이 35/55/75cm, 메 23/26/28㎡·찰 27/30/34㎡) | **표 축·포함범위 다름** — 직접 값 치환 불가. `stone_kind`의 구판 건설품셈 규격별 소요량 fallback은 2026 개정판에서 삭제 검토된 표이므로 현행 연결은 미판정 |
|
||||
| 운반 | 10-4·10-9: 산림 현장용 운반식·적재량 | 공통 8-1-6(2): 운반거리·속도·왕복시간 | **부분 결합** — 산림의 공종·적재 조건과 건설의 시간 산정층을 함께 쓰며 어느 한쪽으로 대체하지 않음 |
|
||||
| 토량환산 | 산림 1-3-2 및 임도 토공 표 | 공통 1-3-2·토공 관련 표 | **미판정** — 산림표 안에도 같은 이름처럼 보이는 토질 행이 다른 계수로 중복되어 이름 대응 금지 |
|
||||
| 재료 할증 | 산림 1-3-1 | 공통 1-3-1 | **부분 결합** — 같은 재료는 산림값 우선, 산림에 없는 재료만 건설값 사용 |
|
||||
| 표면 마무리 | 산림 12-2: 미장공 0.34인, 단위 ㎡당 | 공통 6-1-3: 미장공 0.34인, 단위 100㎡당 | **단위 100배 차이·미판정** — 이름과 숫자만 같고 면적 밑수가 다르므로 연결하면 금액이 100배 어긋남(산림 L6157~6165, 건설 L12~18) |
|
||||
|
||||
> **미판정 재조사 결과:** FW-/CW- 열쇠로 다시 확인했으나 8행의 미판정 사유(대응 절 없음, 내용 차이, 표 축·단위 차이)는 해소되지 않았다. 단위표준·지수판설치·토질은 원문 대조로 확정했지만, 인력은 내용이 다르고 표면마무리는 100배 단위 차이가 있어 연결하지 않는다.
|
||||
|
||||
### 3.5 산림 원문 목차 번호 오기
|
||||
|
||||
| 목차 표기 | 본문에서 확인한 절 | 처리 원칙 |
|
||||
|---|---|---|
|
||||
| `12-17-2`가 철근 펌프카 타설과 무근 진동기 제외에 중복 | 철근은 `12-17-2`, 무근 진동기 제외는 `12-17-3`(본문 L6574, L6586) | 본문 절 번호를 기준으로 열쇠를 붙임 |
|
||||
| `12-24-1`이 뒷채움과 지수판 설치에 중복 | 뒷채움은 `12-24-1`, 지수판 설치는 `12-27-1`(본문 L6671, L6731) | 본문 절 번호를 기준으로 열쇠를 붙임 |
|
||||
|
||||
이는 원문 목차의 중복 표기로, 번호만 읽은 자료 생성에서는 앞 절의 표와 뒤 절의 이름이 합쳐질 수 있다. 연결·갱신 시 목차 번호뿐 아니라 본문 표제와 원문 줄을 함께 확인한다.
|
||||
|
||||
### 3.6 축 범위와 2026-09-17 전수 집계
|
||||
|
||||
| 구분 | 확인 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 산림 총칙 범위 | 제1장 적용기준과 제2장 소요재료 및 기계손료가 총칙 뿌리다. 공종 목록에서 감출 수는 있어도 원가가 읽는 단위·할증·연료·손료계수 표가 있으므로 삭제하지 않는다. | 산림 원문 L522~1770, `axis_policy.json`의 `forest.general_provision_roots` |
|
||||
| 건설 총칙 범위 | 공통부문 제1장 적용기준은 37줄·46표로 총칙이다. 공통부문 제8장 건설기계 351표는 시공능력·손료 값을 함께 가져 총칙 여부가 미확정이다. 유지관리부문 제1장 공통 40표는 원문 대조에서 공종으로 판정했지만 현재 `axis_policy.json`에는 아직 검토 대기로 남아 있어 합치기 전에는 이름만 보고 총칙으로 빼지 않는다. | 건설 공통 제1장 원문 L1 이후, 제8장 원문, 유지관리 제1장 원문; `axis_policy.json`의 `const.general_provision_basis/pending` |
|
||||
| 장 번호 | 건설품셈은 공통·토목·건축·기계설비·유지관리 **각 부문에서 장 번호가 1부터 다시 시작**한다. | 2026 건설품셈 목차, `_meta.md`; 예: 공통 1장 적용기준·토목 1장 도로포장공사 |
|
||||
| 원문 직접 지시 연결 | 산림 원문에서 건설 절을 밝힌 조사 44건을 중복 쌍으로 접고, 한 줄이 두 절을 밝힌 경우는 나눴다. 기존 직접 참조까지 합친 현행 자료에는 FW-/CW- 50쌍이 있으며, `건설표준품셈에 따른다`고만 한 7건은 열쇠 없이 미판정이다. | 산림 원문 L844·L1720~1723·L2740~7820; `work_item_link_2026-01-01.json` |
|
||||
|
||||
성격·밑수 조사는 **조사 장부와 현재 마스터가 아직 같은 판이 아니다.** 2026-09-17 조사 장부는 산림 458표(소요량 279·계수 42·참조 99·생산성 24·미판정 14), 건설 2,192표(소요량 1,206·계수 497·참조 437·생산성 52)로 분류했다. 또한 밑수 미확보 256표 가운데 계수·서식 101표는 곱셈 밑수가 원래 없는 표여서 실질 미확보는 155표로 판정했다. 그러나 현재 `work_item_master_2026-01-01.json`은 붙은 산림표 460개(소요량 281·계수 42·참조 99·생산성 24·미판정 14), `const_work_item_master_2026-01-01.json`은 건설표 2,192개 중 `pum_form` 미판정 881개·`basis_missing` 447개로 기록한다. 따라서 앞의 조사 장부 수치를 프로그램의 현행 마스터 통계로 읽지 않으며, 분류 결과가 자료 파일에 합쳐진 뒤 다시 검산한다.
|
||||
|
||||
## 4. 적용 시 주의
|
||||
|
||||
- **md 변환 한계**: 장 분할은 표제 탐지 기준 — 장 경계 ±1페이지 겹침 가능, 본문 프로즈 어절 공백 붙음. **수치 인용·검증 시 원본 PDF 대조** (각 장 md 머리 주석 동일).
|
||||
- 원문 표에 가로선이 없는 표는 행 병합 오류 가능성 — 발견 시 원문 PDF 기준으로 복원 (2026-08-14 4-1-1 배관해체 사례).
|
||||
- 단위·수량 기준(C.G.S, 소수 처리)·재료 할증은 [수량산출_일반](수량산출_일반.md), 품 할증 계수는 [품셈_적용기준 §2](품셈_적용기준.md).
|
||||
- 개정 대조: 매년 [개정사항 판](../../../original/원가계산/건설공사_표준품셈/2026년_건설공사_표준품셈_개정사항.md)으로 임도 관련 공종 변경 여부 확인 후 데이터셋 갱신.
|
||||
|
||||
## 4. 설계내역서 연결
|
||||
## 5. 설계내역서 연결
|
||||
|
||||
```
|
||||
공종·수량 (04_수량분석정보 각 문서)
|
||||
@@ -67,6 +134,6 @@ last_updated: 2026-08-15
|
||||
|
||||
| 항목 | 출처 | 위치 |
|
||||
|---|---|---|
|
||||
| 산림품셈 우선·건설품셈 보완 | 산림품셈 1-2, 별표2 | [품셈_적용기준 §1](품셈_적용기준.md) |
|
||||
| 산림품셈 우선·건설품셈 보완 | 산림품셈 1-1 제7호, 별표2 | [품셈_적용기준 §1](품셈_적용기준.md) |
|
||||
| 부문·장 구성 (5부문 45장) | 2026 건설품셈 목차 | [_meta](../../../original/원가계산/건설공사_표준품셈/_meta.md) |
|
||||
| 2026 개정 임도 직결 공종 (돌쌓기 계열) | 개정사항 판 + ESTX 공지 대조 | [_meta](../../../original/원가계산/건설공사_표준품셈/_meta.md) 참고란 |
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: form 미판정 895표 분류
|
||||
category: 05_원가정보
|
||||
status: working
|
||||
last_updated: 2026-09-17
|
||||
sources:
|
||||
- resources/data_work_item_master/work_item_master_2026-01-01.json
|
||||
- resources/data_work_item_master/const_work_item_master_2026-01-01.json
|
||||
- resources/data_work_item_link/work_item_link_2026-01-01.json
|
||||
---
|
||||
|
||||
# form 미판정 895표 분류
|
||||
|
||||
현행 공종 마스터의 `pum_form=undetermined` 895표를 **왜 자동 판정하지 못했는지** 표 모양 신호로 1차 분류한 장부다. 산림 14표와 건설 881표를 나눴다.
|
||||
|
||||
## 1. 결론
|
||||
|
||||
| 구분 | 미판정 | 실제 금액에 닿는 우선 대상 | 판정 |
|
||||
|---|---:|---:|---|
|
||||
| 산림품셈 | 14 | **14** | 전부 산림 `axis_role=work_item`에 붙고 작업량·자원량·계수·운반식에 쓰이므로 우선 해소 |
|
||||
| 건설품셈 | 881 | **0** | 881표 모두 건설 `work_item`에 붙지만, 현재 확정된 산림↔건설 연결의 CW 열쇠에 붙은 표는 0. 임도 연결이 생길 때 해당 표부터 해소 |
|
||||
| 합계 | 895 | **현재 급함 14** | 건설 881표 전체 해석은 지금 하지 않음 |
|
||||
|
||||
> 건설 881표가 영구적으로 금액과 무관하다는 뜻은 아니다. 현재 임도 연결고리가 그 표들을 소비하지 않는다는 뜻이다. 사용자가 건설 공종을 직접 세우거나 새 연결을 확정하면 그 공종의 미판정 표가 금액 대상이 된다.
|
||||
|
||||
## 2. 분류 방법
|
||||
|
||||
빈 셀을 제외한 `raw_row`를 아래 우선순위로 한 번씩만 분류했다.
|
||||
|
||||
1. **빈 표** — 값 셀이 하나도 없음.
|
||||
2. **공식·설명 합침** — 긴 설명문 또는 `=`가 든 공식이 한 셀에 합쳐짐.
|
||||
3. **조건·요율표** — `요율·할증률·손율·Type·작업환경·조건` 표로, 자원명·단위·수량 머리가 없음.
|
||||
4. **글값** — `견적처리·별도계상·별산·필요시`처럼 숫자 대신 처리 지시가 값에 들어감.
|
||||
5. **표 머리 소실** — 첫 행 절반 이상이 숫자·규격인데 자원명·단위·수량 머리를 읽지 못함.
|
||||
6. **혼합 행렬** — 다단 머리, 가로 전개, 병합 셀 등 나머지 복합 구조.
|
||||
|
||||
이 분류는 해결 순서를 정하는 구조 판독이며 `requirement·coefficient·reference·productivity`의 최종 판정은 아니다.
|
||||
|
||||
## 3. 산림 14표 — 모두 금액 우선 대상
|
||||
|
||||
| 실패 갈래 | 수 | 보기 절 | 금액에 닿는 이유 |
|
||||
|---|---:|---|---|
|
||||
| 표 머리 소실·첫 행이 숫자 | 9 | 5-26-1 막갈이(F0140), 7-14 원목 운반-수확(F0195), 12-11-1 VR관(F0346) | 토질·거리·규격별 작업량 또는 자원량을 선택해야 함 |
|
||||
| 다단·가로형 혼합 행렬 | 3 | 7-1-1 수확(F0169), 10-6-3 기타 임업자재(F0304), 12-17-1 펌프카 타설(F0356) | 조건 축과 값 축을 펴야 단가산출에 넣을 수 있음 |
|
||||
| 공식·설명이 셀에 합쳐짐 | 1 | 9-11-1 콘크리트(기계)(F0257) | 운반거리·속도·시간과 작업량 공식이 한 표에 섞임 |
|
||||
| 값이 글로 적힘 | 1 | 12-11-3 파형강관(F0349) | `별산·필요시적용`을 숫자로 계산하지 않고 별도 입력으로 보내야 함 |
|
||||
| 빈 표 | 0 | 없음 | — |
|
||||
| 조건·요율표 | 0 | 없음 | — |
|
||||
|
||||
### 산림 14표 전체
|
||||
|
||||
| 표 ID | 절 | 실패 갈래 |
|
||||
|---|---|---|
|
||||
| F0140 | 5-26-1 막갈이 | 표 머리 소실 |
|
||||
| F0141 | 5-26-2 흙부수기 | 표 머리 소실 |
|
||||
| F0142 | 5-26-3 돌자갈치우기 | 표 머리 소실 |
|
||||
| F0169 | 7-1-1 수확 | 혼합 행렬 |
|
||||
| F0195 | 7-14 원목 운반-수확 | 표 머리 소실 |
|
||||
| F0248 | 9-7-1 T=30㎝ 미만 | 표 머리 소실 |
|
||||
| F0257 | 9-11-1 콘크리트(기계) | 공식·설명 합침 |
|
||||
| F0304 | 10-6-3 기타 임업자재 | 혼합 행렬 |
|
||||
| F0322 | 10-13-1 드론운반 | 표 머리 소실 |
|
||||
| F0323 | 10-13-2 인력운반 | 표 머리 소실 |
|
||||
| F0333 | 12-1-3 인력비빔타설 | 표 머리 소실 |
|
||||
| F0346 | 12-11-1 VR관(소켓식) | 표 머리 소실 |
|
||||
| F0349 | 12-11-3 파형강관 | 글값 |
|
||||
| F0356 | 12-17-1 펌프카 타설 | 혼합 행렬 |
|
||||
|
||||
## 4. 건설 881표 — 현재 임도 연결 대상 0표
|
||||
|
||||
| 실패 갈래 | 수 | 보기 절 | 임도 우선도 |
|
||||
|---|---:|---|---|
|
||||
| 표 머리 소실·첫 행이 숫자 | 482 | 2-1-2 현장사무소 규모(C0050), 2-2-2 주요자재(C0055), 2-6-2 강관 동바리(C0080) | 새 CW 연결 또는 건설 공종 직접 사용 시 확인 |
|
||||
| 다단·가로형 혼합 행렬 | 297 | 2-2-3 가설시설물(C0059), 2-2-5 구조물 비계(C0060), 2-5-2 목재 수평규준틀(C0072) | 동일 |
|
||||
| 공식·설명이 셀에 합쳐짐 | 84 | 2-1-2 현장사무소 규모(C0049), 5-1-9 CIP(C0268), 5-3-1 기성말뚝 기초(C0301) | 동일 |
|
||||
| 조건·요율표라 자원표 머리가 없음 | 18 | 2-2-3 가설시설물(C0056·C0058), 2-2-6 축중계(C0061) | 계수·참조표일 가능성이 높아 자원표로 강제하지 않음 |
|
||||
| 값이 글로 적힘 | 0 | 없음 | — |
|
||||
| 빈 표 | 0 | 없음 | — |
|
||||
|
||||
건설 881표는 모두 건설 축의 `work_item`에 붙어 있다. 그러나 `work_item_link_2026-01-01.json`에서 `status=확정`인 CW 열쇠와 교차하면 0표다. 따라서 임도 계산의 현재 급한 범위는 산림 14표이고, 건설은 연결 확정 시 해당 CW 공종만 좁혀 판독한다.
|
||||
|
||||
## 5. 처리 순서
|
||||
|
||||
1. 산림 14표의 원문 머리·단위·조건 축을 복원한다.
|
||||
2. F0349의 `별산·필요시적용`은 숫자로 만들지 않고 사용자 입력/별도계상 상태로 보존한다.
|
||||
3. 각 표의 `pum_form`과 밑수를 함께 확정한다. 모양만 고치고 계산 성격을 추정하지 않는다.
|
||||
4. 건설 881표는 새 CW 연결 또는 직접 사용이 생긴 공종만 판독한다.
|
||||
5. 갱신 뒤 `undetermined` 수와 확정 연결 CW 교차 수를 다시 센다.
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: 산림 form 미판정 14표 원문 읽기
|
||||
category: 05_원가정보
|
||||
status: verified
|
||||
last_updated: 2026-09-17
|
||||
sources:
|
||||
- 산림청고시 제2025-82호 산림사업 표준품셈 PDF
|
||||
- resources/knowledge/original/행정규칙/임도 품셈 적용기준 (현 산림사업 표준품셈)/첨부/(산림청고시 제2025-82호) 산림사업 표준품셈.md
|
||||
---
|
||||
|
||||
# 산림 form 미판정 14표 원문 읽기
|
||||
|
||||
`work_item_master_2026-01-01.json`에서 `pum_form=undetermined`인 산림 14표를 공식 PDF 지면과 변환 MD로 대조했다. **자료값은 수정하지 않고**, 각 표를 어떤 머리·축·상태로 읽어야 하는지만 기록한다.
|
||||
|
||||
## 1. 결론
|
||||
|
||||
- 13표는 PDF 지면에서 머리와 읽기 축을 확인했다.
|
||||
- F0333은 머리 구조는 확인했지만 공식 PDF 자체가 앞 표와 같은 숫자를 온도표에 반복한다. 원문 이상 가능성이 있어 숫자의 의미를 임의 보정하면 안 된다.
|
||||
- `별산·별도계산·필요시·설계수량`은 숫자 0이 아니라 별도 입력/계상 상태다.
|
||||
|
||||
## 2. 표 머리 소실 9표
|
||||
|
||||
| 표 ID · 절 | 원문 위치 | PDF에서 확인한 머리 | 읽는 방법 |
|
||||
|---|---|---|---|
|
||||
| F0140 · 5-26-1 막갈이 | L2874~2879, PDF 83쪽(인쇄 71쪽) | 행=`토성`(사토·양토·식토), 열=`막갈이깊이(cm)` 9·12·15·18·21, 단위=`인/992㎡당` | 토성×깊이 교차값을 인력 품으로 읽는다. 단위가 표 아래에 있어 파서가 놓쳤다. |
|
||||
| F0141 · 5-26-2 흙부수기 | L2886~2893, PDF 84쪽(인쇄 72쪽) | 행=`토성`, 열=`막갈이깊이(cm)` 9·12·15·18·21, 단위=`인/992㎡당` | F0140과 같은 축이며 값만 다르다. 표 아래 단위를 붙인다. |
|
||||
| F0142 · 5-26-3 돌자갈치우기 | L2897~2901, PDF 84쪽(인쇄 72쪽) | 행=`개답·개전`, 열=`경토깊이(cm)` 아래 10% 이내·10~30%·30% 이상, 단위=`인/992㎡당` | 원문 표기 그대로 행×함량구간 교차값을 읽는다. `토성/경토깊이` 머리와 백분율 열의 의미가 어색하므로 이름을 고쳐 쓰지 않는다. |
|
||||
| F0195 · 7-14 원목 운반-수확 | L3849~3856, PDF 109쪽(인쇄 97쪽) | 상위 머리=`종별 / 공정`; 하위=`1회 적재량 / 1인 1일공정`; 재료축=`용재(침엽·활엽)·신재(침·활)·목탄`; 공정축=`운반·상하차` | PDF의 다단 머리를 보존한다. ㎥·kg·연km처럼 열마다 단위가 다르므로 한 공통 단위를 붙이지 않는다. |
|
||||
| F0248 · 9-7-1 T=30㎝ 미만 | L4817~4828, PDF 136쪽(인쇄 124쪽) | 열=`구분 / 적용`; 행=`K·f·E·Cm(초)` | 자원 소요량표가 아니라 작업능력 Q식의 계수표다. K=0.55, f=0.67, E=0.35, Cm=22초(180°)를 각각 이름 있는 계수로 읽는다. |
|
||||
| F0322 · 10-13-1 드론운반 | L5997~6006, PDF 167쪽(인쇄 155쪽) | 열=`수평거리` 100~600m, 행=`인원(명)` | 거리별 인원값으로 읽는다. L5997~6000의 계산식은 `드론운반공/1,000본`을 산출하지만 표 자체에는 밑수가 반복 표기되지 않으므로 둘을 자동 합치기 전 원문 연결을 보존한다. |
|
||||
| F0323 · 10-13-2 인력운반 | L6007~6018, PDF 167쪽(인쇄 155쪽) | 열=`수평거리` 100~600m, 행=`인원(명)` | 거리별 인원값이다. 표에 별도 밑수 문구가 없으므로 `명` 이외의 기준량을 추정하지 않는다. |
|
||||
| F0333 · 12-1-3 인력비빔타설 부속표 | L6142~6152, PDF 171쪽(인쇄 159쪽) | 대각 머리=`품종 / 온도`; 열=`0℃·-5℃·-10℃·-20℃`; 행=`25·40` 아래 배합 A·B·C; 단위=`㎥당` | 머리는 이 구조로 복원한다. 다만 값 357·893·931 등은 직전 골재·배합 재료량 표(L6128~6135)와 동일하게 반복된다. 공식 PDF 자체의 이상 가능성이 있어 `시후카 양`으로 단정하거나 값을 고치지 않는다. |
|
||||
| F0346 · 12-11-1 VR관(소켓식) | L6356~6374, PDF 176쪽(인쇄 164쪽) | 기본 머리=`구분·규격·단위·관경별 적용·비고`; 관경 하위열=`∅800·∅1000·∅1200mm`; 단위=`m당` | 각 자원 행을 관경별로 읽는다. `별도계산·설계수량`은 숫자값이 아니라 별도계상 상태다. |
|
||||
|
||||
## 3. 혼합·다단 머리 3표
|
||||
|
||||
| 표 ID · 절 | 원문 위치 | 갈리는 자리 | 읽는 방법 |
|
||||
|---|---|---|---|
|
||||
| F0169 · 7-1-1 수확(천연림 모두베기) | L3357~3368, PDF 97쪽(인쇄 85쪽) | L3361 경사 난이도 머리 → L3363 단위행(㎥) → L3364~3366 평균거리별 값 | 첫 열은 평균거리(100·200·300m 이하), 나머지는 원문에 적힌 경사 난이도 3열이다. 단위행을 값 행으로 읽지 않는다. |
|
||||
| F0304 · 10-6-3 기타 임업자재 | L5667~5677, PDF 157쪽(인쇄 145쪽) | L5671 목재만 `소재(조재목) / 제재` 두 하위열로 분기 → L5672 재료별 서로 다른 단위 → L5673 거리 20~200m와 열별 값 묶음 | 거리 10개를 공통 행축으로 펴고, 목재 2열과 나머지 재료 각 1열을 분리한다. 단위는 인/㎥·인/100속·인/100매·인/100개·인/100본·인/1000㎡·인/톤으로 열마다 보존한다. |
|
||||
| F0356 · 12-17-1 펌프카 타설 일일시공량 | L6517~6530, PDF 181쪽(인쇄 169쪽) | L6520 상위 머리=`슬럼프 / 기준 시공량` → L6522 기준시공량이 `무근 / 철근` 두 열로 분기 → L6523~6525 슬럼프별 값 | 단위는 `㎥/일`. 슬럼프 8~12·15·18cm 이상별로 무근/철근 값을 읽고, 이후 f1·f2 계수는 별도 표로 적용한다. |
|
||||
|
||||
## 4. 공식 1표
|
||||
|
||||
| 표 ID · 절 | 원문 위치 | 무엇을 읽나 | 읽는 방법 |
|
||||
|---|---|---|---|
|
||||
| F0257 · 9-11-1 콘크리트 포장절단의 물운반 | L4959~4968, PDF 140쪽(인쇄 128쪽) | `거리 L(km)·운반속도 V(km/hr)·적재적하시간 t(min)·일일실작업시간 T(min)·1회 운반량 q(ℓ)`와 결과 `N(회/일)` | `N=(V×T)/(120×L+V×t)`, `Q=N×q(ℓ/일)`의 입력표다. L=0.1, V=2.5, t=4.0, T=480-30, q=25를 이름 있는 변수로 읽고 N·Q는 계산값으로 둔다. |
|
||||
|
||||
## 5. 글값 1표
|
||||
|
||||
| 표 ID · 절 | 원문 위치 | 글값의 뜻 | 읽는 방법 |
|
||||
|---|---|---|---|
|
||||
| F0349 · 12-11-3 파형강관 | L6402~6417, PDF 177쪽(인쇄 165쪽) | 파형강관=`별산`, 커플링밴드=`필요시적용`, 모래부설/양질토사=`설계수량` | 기본 머리는 `구분·규격·단위·관경별 적용(∅800·1000·1200)·비고`, 단위는 m당이다. 세 글값은 0이 아니라 각각 별도 가격, 조건부 추가, 설계 입력 상태로 보존한다. 커플링밴드는 [주]①의 `1EA/6m`를 필요 시 별도 산정한다. |
|
||||
|
||||
## 6. 자료 반영 시 주의
|
||||
|
||||
- 이 문서는 원문 읽기 명세이며 마스터 값을 직접 바꾸지 않는다.
|
||||
- PDF 아래·위에 떨어진 단위도 표와 함께 보존한다.
|
||||
- 다단 머리는 한 줄로 합치지 말고 상위축·하위축을 나눈다.
|
||||
- 공식의 입력값과 계산 결과를 같은 자원값으로 저장하지 않는다.
|
||||
- F0333은 원문 정오 근거가 나오기 전 숫자 의미를 확정하지 않는다.
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: 현재 프로그램 미판정 목록
|
||||
category: 05_원가정보
|
||||
status: working
|
||||
last_updated: 2026-09-17
|
||||
sources:
|
||||
- resources/data_work_item_link/work_item_link_2026-01-01.json
|
||||
- resources/data_work_item_master/work_item_master_2026-01-01.json
|
||||
- resources/data_work_item_master/const_work_item_master_2026-01-01.json
|
||||
- resources/knowledge/technical_info/01_임도/05_원가정보/산림품셈_결측밑수_155개_자리명세.json
|
||||
- antigravity commit 2dab2ce2
|
||||
---
|
||||
|
||||
# 현재 프로그램 미판정 목록
|
||||
|
||||
2026-09-17 판에서 프로그램이 자동으로 결정하지 못하는 자리를 한 벌로 모은 장부다. **미판정은 임의 기본값으로 메우지 않는다.** 금액 영향은 공종이 실제 설계에 서는 경우를 기준으로 가렸다.
|
||||
|
||||
## 1. 한눈에 보기
|
||||
|
||||
| 층 | 미판정 수 | 금액 영향 |
|
||||
|---|---:|---|
|
||||
| 산림↔건설 연결고리 | 10행 / 전체 78행 | 확정 차단 3건, 조건부 차단 1건, 나머지는 산림값 사용 또는 표시·근거 문제 |
|
||||
| 공종 축 역할 | `axis_role=미판정` 0줄; 대신 표·자식이 없는 `empty` 산림 11줄·건설 83줄 | 그 줄 자체에는 계산표가 없어 직접 금액은 만들지 않음. 실제 표가 잘못 빠졌는지는 별도 확인 필요 |
|
||||
| 표 모양 | 산림 14표·건설 881표가 `pum_form=undetermined` | 선택된 표가 소요량인지 계수·참조인지 몰라 자동 계산을 보류해야 함 |
|
||||
| 현행 마스터의 밑수 미확보 | 산림 115표·건설 447표 | 소요량·생산성 표이면 곱셈 기준량이 없어 자동 계산이 막힘 |
|
||||
| 산림 원문 별도 감사 | 결측 후보 256표 중 무차원 계수·서식 101표 제외, 읽어야 할 밑수 155표 | 155표 중 소요량 79·생산성 11 = **금액에 닿는 90표**. 참조 60·형태 미판정 5표 |
|
||||
|
||||
> 현행 마스터의 `basis_missing` 115·447과 원문 감사의 155는 집계 정의와 생성 시점이 다르다. 더하거나 같은 수로 보지 않는다. 155표의 실제 자리는 `산림품셈_결측밑수_155개_자리명세.json`에 표 ID·절·원문 줄·읽기 규칙으로 남아 있다(본문 위 48, 표 머리 107).
|
||||
|
||||
## 2. 연결고리 미판정 10건
|
||||
|
||||
### ㉮ 근거될 현행 절이 없음 — 4건
|
||||
|
||||
| 대상 | 미판정 이유 | 금액 영향 |
|
||||
|---|---|---|
|
||||
| 산림 제2·3·4·6·7장 산림사업 공종군 | 건설 5부문 45장에서 같은 공종표를 확인하지 못함 | **차단 아님** — 산림품셈이 정본. 비슷한 조경·유지관리 품으로 자동 대체하지 않음 |
|
||||
| 산림 13-13-1 목재틀흙막이 | 건설품셈에 같은 구조·규격별 절이 없음 | **차단 아님** — 산림표 사용. 건설 보완값은 없음 |
|
||||
| 산림 9-7-1 무근콘크리트 깨기 | 원문 L4810은 `건설표준품셈 토목`만 쓰고 절을 밝히지 않음 | **차단 아님** — 같은 공종의 브레이커 FW-00260↔CW-00330 연결과 산림 고유 효율값은 별도로 확정됨 |
|
||||
| 산림 9-12-1 인력 터파기 | 원문 L4988은 2018 건설 토목 3-1-3만 밝히며 현행 2026 대응 절 근거가 없음 | **확정 차단** — 해당 공종이 서면 현행 품을 자동 선택할 수 없어 수동 근거가 필요 |
|
||||
|
||||
### ㉯ 이름은 닮았으나 내용이 다름 — 2건
|
||||
|
||||
| 대상 | 미판정 이유 | 금액 영향 |
|
||||
|---|---|---|
|
||||
| 산림·건설 1-3-2 토량환산 | 같은 토질명처럼 보여도 계수와 적용표가 다름 | 산림 공종은 산림값 우선이므로 연결 자체는 차단 아님. 이름 자동 매칭 시 토공량·금액 오산 위험 |
|
||||
| 산림·건설 1-2-5 | 산림은 노임단가 규정, 건설은 작업반장·안전인력 등 인력 편성 규정 | 직접 차단 아님. 같은 절 번호라는 이유로 연결하면 다른 규칙이 붙음 |
|
||||
|
||||
### ㉰ 표 축·단위가 달라 못 이음 — 3건
|
||||
|
||||
| 대상 | 미판정 이유 | 금액 영향 |
|
||||
|---|---|---|
|
||||
| 뿌리제거: 산림 9-21 ↔ 건설 공통 3-9-2 | 산림은 소밀도별 0.80/1.01/1.22hr, 건설은 굴착기+그래플 3.76hr/1,000㎡ | 산림 품을 쓰면 직접 차단 아님. 건설값으로 치환하면 최소 비교값 기준 4.7배 오산 |
|
||||
| 돌쌓기: 산림 13-4-4 ↔ 건설 공통 7-1 | 산림은 석재·찰/메·뒷길이별 ㎡당, 건설은 작업조 1일 시공량 | **조건부 차단** — 현행 규격이 산림표에 없고 구판 건설 fallback에만 있으면 수동 판정 필요 |
|
||||
| 표면 마무리: 산림 12-2 ↔ 건설 공통 6-1-3 | 숫자 0.34는 같지만 산림은 ㎡당, 건설은 100㎡당 | **확정 차단** — 단위 불일치 해소 전 자동 연결 금지. 잘못 연결하면 100배 오산 |
|
||||
|
||||
### ㉱ 규격이 원문에 없음 — 1건
|
||||
|
||||
| 대상 | 미판정 이유 | 금액 영향 |
|
||||
|---|---|---|
|
||||
| 산림 2-2-9 1톤 방제차량 | 산림 L1702는 건설품셈 준용을 지시하지만 건설 8-3 손료표에는 덤프트럭 2.5톤 이상만 있고 1톤 방제차량 규격이 없음 | **확정 차단** — 8-1-2 [주]④에 따른 개별 산정 전에는 손료·운전경비를 만들 수 없음 |
|
||||
|
||||
## 3. 공종 축과 표 판독 미판정
|
||||
|
||||
### 3.1 `axis_role`
|
||||
|
||||
- 현행 두 마스터에 `axis_role="미판정"` 값은 없다.
|
||||
- 아직 역할을 확정하지 못한 것에 가장 가까운 값은 `empty`다: 산림 11줄, 건설 83줄.
|
||||
- `empty`는 표도 자식도 없는 목차 줄이라 직접 계산값은 없다. 다만 원문 표가 다른 줄에 잘못 붙었거나 누락된 것인지는 별도 대조해야 한다.
|
||||
- 산림 예: 3-4-1 신설 및 보수 기준, 3-4-2 임산물 운반로 및 작업로 신설비 산정, 9-17-1 비탈면 다짐. 건설 예: 공통 2-2-4 구조물 동바리, 8-1-5 기계경비 용어와 정의.
|
||||
|
||||
### 3.2 표 모양과 밑수
|
||||
|
||||
| 판독 상태 | 산림 | 건설 | 프로그램 처리 |
|
||||
|---|---:|---:|---|
|
||||
| `pum_form=undetermined` | 14 | 881 | 소요량·계수·참조·생산성 중 무엇인지 확정 전 자동 계산 금지 |
|
||||
| `basis_missing` | 115 | 447 | 소요량·생산성 표는 기준량을 읽기 전 계산 금지. 계수·참조표는 밑수가 원래 없을 수 있어 먼저 성격 판정 |
|
||||
| 산림 원문에서 회수 가능한 밑수 | 155 | 해당 없음 | 본문 윗줄 48·표 머리 107. 소요량·생산성 90표부터 반영 우선 |
|
||||
|
||||
## 4. 금액 계산을 실제로 막는 우선순위
|
||||
|
||||
1. **1톤 방제차량** — 현행 건설품셈 규격 자체가 없어 개별 산정 필요.
|
||||
2. **9-12-1 인력 터파기** — 2018 절과 2026 절 대응 근거 필요.
|
||||
3. **12-2 표면 마무리** — ㎡와 100㎡ 중 어느 판 단위가 맞는지 원문 정정·해석 필요.
|
||||
4. **13-4-4 돌쌓기 규격 fallback** — 해당 규격을 쓰는 설계에서 현행 근거를 수동 지정해야 함.
|
||||
5. **밑수 90표** — 산림 결측 155표 중 소요량 79·생산성 11표. 표가 실제 공종에 선택되면 기준량 판독 전 계산 보류.
|
||||
6. **건설 `basis_missing` 447표·형태 미판정 881표** — 임도 설계에 선택되는 표부터 원문 대조. 전부가 금액 공종은 아님.
|
||||
|
||||
## 5. 갱신 원칙
|
||||
|
||||
- 근거 절·원문 줄·단위가 확인된 항목만 이 목록에서 제거한다.
|
||||
- 연결고리 행을 확정하면 FW-/CW- 열쇠 존재와 중복을 검사한다.
|
||||
- `pum_form`과 밑수는 같은 표에서 함께 확인한다. 계수·참조표에 억지 밑수를 넣지 않는다.
|
||||
- 집계 수는 `work_item_link`, 산림·건설 공종 마스터, 결측밑수 자리명세를 다시 읽어 갱신한다.
|
||||
@@ -53,6 +53,9 @@ WORK_ITEM_KEYS = {
|
||||
"origin",
|
||||
# 공종 코드가 본 품셈 판 — B09 가 마스터 판과 다르면 값을 안 씀(명세 17장 · 2026-09-13 추가).
|
||||
"pum_edition",
|
||||
# 불변 열쇠(FW-·AX-) — 목차 번호가 개정으로 밀려도 안 바뀜(2026-09-17 8-6 이행 길목 ·
|
||||
# 받는 쪽 B09 `parse_handoff` 가 읽음). 코드 없는 줄은 None.
|
||||
"work_item_key",
|
||||
}
|
||||
|
||||
#: **자재 줄**이 내보내는 칸. ⚠ 여기에 `work_item_code` 가 들어가면 안 된다(축이 다르다).
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -21,7 +22,13 @@ LABELS_PATH = ROOT / "resources" / "data_master_labels" / "labels_2026-01-01.jso
|
||||
#: 브레인 갈래 나눔표(2026-09-15). 폴더 장부(_manifest)는 그 31 밖이라 따로 센다.
|
||||
#: 2026-09-16 사용자 판정 — `structure_unit_observed`(울진소광 한 현장 관찰값)와
|
||||
#: `revetment_sabang`(교본값에 설명 글 섞임)은 기초데이터가 아니라 **참고 사례**다.
|
||||
BRAIN_KIND_COUNTS = {"logic": 15, "base_value": 9, "byproduct": 4, "seed": 1, "reference": 2}
|
||||
BRAIN_KIND_COUNTS = {
|
||||
"logic": 19,
|
||||
"base_value": 9,
|
||||
"byproduct": 6,
|
||||
"reference": 2,
|
||||
"seed": 1,
|
||||
}
|
||||
MANIFEST_PREFIX = "manifest_"
|
||||
|
||||
META_KEYS = {
|
||||
@@ -130,7 +137,9 @@ def test_갈래_나눔이_브레인_표와_같다(labels):
|
||||
continue
|
||||
counted[entry["kind"]] = counted.get(entry["kind"], 0) + 1
|
||||
assert counted == BRAIN_KIND_COUNTS
|
||||
assert sum(counted.values()) == 31
|
||||
# 2026-09-17 31 → 37 — 공종 축 산출 일곱을 이름표에 올림(건설 마스터·미판정·밑수 · 열쇠 장부 둘 ·
|
||||
# 구실 잣대 · 건설 폴더 지문). 지문은 manifest 라 이 셈에서 빠진다.
|
||||
assert sum(counted.values()) == 37
|
||||
|
||||
|
||||
def test_갈래_이름이_다_풀려_있다(labels):
|
||||
@@ -192,6 +201,31 @@ def test_표_이름이_비지_않았다(labels):
|
||||
assert not blank, f"한글 이름이 빈 표: {blank}"
|
||||
|
||||
|
||||
def test_이름이_영문으로_떨어진_칸이_없다(labels):
|
||||
"""⚠ 빈칸이 아니라 **영문 키가 그대로 이름 자리에 앉은** 것을 잡는다(2026-09-17 데스크탑 서브).
|
||||
|
||||
새 파일을 이름표에 올릴 때 이름을 못 지으면 키를 그대로 베끼기 쉬운데, 그러면 화면에
|
||||
`general_provision_roots` 같은 영문이 뜬다. 한글이 한 글자도 없으면 이름을 안 지은 것이다.
|
||||
"""
|
||||
hangul = re.compile(r"[가-힣]")
|
||||
bad = []
|
||||
for entry in labels["files"]:
|
||||
for table in entry["tables"]:
|
||||
for column in table["columns"]:
|
||||
if not hangul.search(column["name_ko"]):
|
||||
bad.append(f"{entry['file_id']}::{table['key']}/{column['key']}")
|
||||
if not hangul.search(table["name_ko"]):
|
||||
bad.append(f"{entry['file_id']}::{table['key']}")
|
||||
for group in entry["value_groups"]:
|
||||
if not hangul.search(group["name_ko"]):
|
||||
bad.append(f"{entry['file_id']}::{group['key']}")
|
||||
for merged in labels["merged_tables"]:
|
||||
for column in merged["columns"]:
|
||||
if not hangul.search(column["name_ko"]):
|
||||
bad.append(f"{merged['key']}/{column['key']}")
|
||||
assert not bad, f"영문으로 뜨는 칸: {bad}"
|
||||
|
||||
|
||||
def test_값_묶음_이름이_비지_않았다(labels):
|
||||
blank = [
|
||||
f"{entry['file_id']}::{group['key']}"
|
||||
@@ -470,6 +504,8 @@ MERGED_KEYS = (
|
||||
"stone_kind",
|
||||
"masonry_class",
|
||||
"machine_productivity",
|
||||
"work_item_forest", # 2026-09-17 공종 축 산림
|
||||
"work_item_const", # 2026-09-17 공종 축 건설
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""공종 불변 열쇠 **길목** — PLAN_공종축 8-6 이행(2026-09-17 브레인 · 코덱스 조사).
|
||||
|
||||
잣대 = **금액 불변.** 열쇠를 더하는 일이지 값을 바꾸는 일이 아님 —
|
||||
같은 인계로 세운 내역서가 열쇠가 있든(새 인계) 없든(옛 인계) **한 원도** 안 달라야 함.
|
||||
길목은 경계 두 곳뿐(B08 인계 출구 · B09 인계 입구) · `work_item_code` 는 목차 번호로 그대로 남음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff
|
||||
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 B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities_Input import parse_handoff
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices
|
||||
from common_util.common_util_work_item_key import MASTER_DIR, attach_keys, work_item_key
|
||||
|
||||
MAPPING = MASTER_DIR.parent / "data_work_item_mapping" / "work_item_mapping_2026-01-01.json"
|
||||
|
||||
|
||||
def _strip_keys(value):
|
||||
"""옛 인계 — 길목이 더한 칸을 뗌."""
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
k: _strip_keys(v)
|
||||
for k, v in value.items()
|
||||
if k not in ("work_item_key", "unkeyed_work_item_codes")
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_strip_keys(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _handoff() -> dict:
|
||||
"""토공·운반·구조물·자재가 다 선 인계 — 금액이 실제로 붙는 줄이 여럿."""
|
||||
unit = build_unit_table(
|
||||
[
|
||||
{
|
||||
"structure_id": "s1",
|
||||
"type_id": "masonry_wet",
|
||||
"start_m": 35.0,
|
||||
"end_m": 45.0,
|
||||
"options": {"height_m": 1.5, "length_m": 10.0},
|
||||
}
|
||||
]
|
||||
)
|
||||
rows = [
|
||||
{"group": "흙깎기", "item": "토사", "spec": "", "unit": "㎥", "amount": 1234.5},
|
||||
{"group": "측구터파기", "item": "토사", "spec": "", "unit": "㎥", "amount": 77.0},
|
||||
{"group": "층따기", "item": "", "spec": "", "unit": "㎥", "amount": 12.0},
|
||||
]
|
||||
haul = {
|
||||
"rows": [
|
||||
{
|
||||
"equipment": "dump_truck",
|
||||
"ground": "토사",
|
||||
"volume_m3": 500.0,
|
||||
"average_distance_m": 1200.0,
|
||||
"in_bill": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
return build_handoff(
|
||||
summary_table={"rows": rows},
|
||||
haul_table=haul,
|
||||
unit_quantity_table=unit,
|
||||
material_table=build_material_table(unit),
|
||||
)
|
||||
|
||||
|
||||
def test_목차_코드는_불변_열쇠로_난수_코드는_그대로() -> None:
|
||||
forest = json.loads(
|
||||
(MASTER_DIR / "work_item_master_2026-01-01.json").read_text(encoding="utf-8")
|
||||
)
|
||||
for item in forest["work_items"]:
|
||||
assert work_item_key(item["work_item_code"], forest["pum_edition"]) == item["work_item_key"]
|
||||
assert work_item_key("FP-09-03-02") == work_item_key("FP-09-03-02", "2026-01-01")
|
||||
assert re.fullmatch(r"CW-\d{5}", work_item_key("CP-01-03-02-01") or "")
|
||||
assert work_item_key("AX-WK-c0842a0d") == "AX-WK-c0842a0d" # 난수 8자리 — 이미 불변
|
||||
assert work_item_key("AX-ST-0123abcd") == "AX-ST-0123abcd"
|
||||
assert work_item_key("FP-09-03-02", "1999-01-01") is None # 판이 다르면 안 줌
|
||||
assert work_item_key("FP-99-99") is None and work_item_key(None) is None
|
||||
|
||||
|
||||
def test_매핑의_코드가_다_열쇠를_받음() -> None:
|
||||
"""B08 이 인계에 싣는 코드의 샘 — 하나라도 못 받으면 그 줄은 열쇠 없이 감."""
|
||||
text = MAPPING.read_text(encoding="utf-8")
|
||||
codes = set(re.findall(r'"((?:FP|CP|AX-WK|AX-ST)-[0-9A-Za-z-]+)"', text))
|
||||
assert len(codes) > 50
|
||||
assert [c for c in sorted(codes) if work_item_key(c, "2026-01-01") is None] == []
|
||||
|
||||
|
||||
def test_B08_인계_출구에서_열쇠가_붙고_목차_코드는_그대로() -> None:
|
||||
handoff = _handoff()
|
||||
coded = [row for row in handoff["work_items"] if row.get("work_item_code")]
|
||||
assert len(coded) >= 5
|
||||
for row in coded:
|
||||
assert row["work_item_key"] == work_item_key(row["work_item_code"], row["pum_edition"])
|
||||
assert row["work_item_code"].startswith(("FP-", "AX-")) # 사람이 보는 번호는 남음
|
||||
assert handoff["unkeyed_work_item_codes"] == []
|
||||
assert all("work_item_key" not in row for row in handoff["materials"]) # 자재는 공종 축 아님
|
||||
|
||||
|
||||
def test_못_찾은_코드는_지어내지_않고_목록() -> None:
|
||||
rows = [
|
||||
{"work_item_code": "FP-99-99"},
|
||||
{"work_item_code": None},
|
||||
{"work_item_code": "FP-09-18"},
|
||||
]
|
||||
assert attach_keys(rows) == ["FP-99-99"]
|
||||
assert rows[0]["work_item_key"] is None and rows[1]["work_item_key"] is None
|
||||
assert rows[2]["work_item_key"].startswith("FW-")
|
||||
|
||||
|
||||
def test_B09_입구는_옛_인계에도_같은_열쇠() -> None:
|
||||
new, _ = parse_handoff(_handoff())
|
||||
old, _ = parse_handoff(_strip_keys(copy.deepcopy(_handoff())))
|
||||
assert [w.work_item_key for w in old] == [w.work_item_key for w in new]
|
||||
assert any(w.work_item_key.startswith("FW-") for w in new)
|
||||
|
||||
|
||||
def test_금액_불변_열쇠가_있든_없든_내역서가_한_원도_안_다름() -> None:
|
||||
"""⭐ 이번 일감의 잣대(브레인) — 새 인계(열쇠 있음)와 옛 인계(열쇠 없음)로 세운 내역서가 같음."""
|
||||
build = build_unit_prices()
|
||||
new = build_bill(copy.deepcopy(_handoff()), build=build)
|
||||
old = build_bill(_strip_keys(copy.deepcopy(_handoff())), build=build)
|
||||
assert bill_summary(new) == bill_summary(old)
|
||||
assert [row.as_dict() for row in new.rows] == [row.as_dict() for row in old.rows]
|
||||
assert [row.as_dict() for row in new.material_rows] == [
|
||||
row.as_dict() for row in old.material_rows
|
||||
]
|
||||
priced = [row for row in new.rows if not row.is_group and row.amount_krw]
|
||||
assert len(priced) >= 3 and new.body_total_krw > 0 # 금액이 실제로 선 줄로 잼
|
||||
|
||||
|
||||
#: ⭐ 이행 **전** 코드(4bfbf98f^ · 길목 없음)로 위 `_handoff()` 를 세워 뜬 금액 — 2026-09-17 랩탑 메인.
|
||||
#: 이행 뒤 코드도 내역서 줄 18·성분·원가계산서 지문까지 한 글자 같았음(옛 두 파일을 따로 얹어 맞댐).
|
||||
#: ⚠ 단가 자료(노임·기계·자재·요율)가 바뀌면 이 수도 바뀜 — 그땐 길목 탓이 아닌지 먼저 볼 것
|
||||
#: (열쇠 있든 없든 같은지 = 위 시험) · 길목 탓이면 고칠 것은 코드, 아니면 다시 뜸.
|
||||
PRE_MIGRATION_KRW = {
|
||||
"material_cost": "1158206",
|
||||
"labor_cost": "4400597",
|
||||
"expense": "1991430",
|
||||
"direct_construction_cost": "5983724",
|
||||
"net_construction_cost": "7550233",
|
||||
"total_cost": "9203637",
|
||||
"grand_total": "10124000",
|
||||
}
|
||||
|
||||
|
||||
def test_금액_불변_이행_전_코드로_뜬_원가계산서와_한_원도_같음() -> None:
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities import cost_input_from_bill
|
||||
from B09_Estimation.B09_Estimation_Engine_Cost import calculate_cost
|
||||
|
||||
bill = build_bill(copy.deepcopy(_handoff()), build=build_unit_prices())
|
||||
totals = calculate_cost(cost_input_from_bill(bill)).totals
|
||||
assert {key: str(totals[key]) for key in PRE_MIGRATION_KRW} == PRE_MIGRATION_KRW
|
||||
@@ -0,0 +1,359 @@
|
||||
"""공종 축 불변 열쇠 검사 — 2026-09-17 브레인 ①.
|
||||
|
||||
여기서 못박는 것은 하나다. **금액이 움직이면 안 된다.**
|
||||
열쇠를 얹는 일은 값을 바꾸는 일이 아니므로, 새 칸을 떼어내면 옛 벌과 **한 글자도 달라선 안 된다.**
|
||||
그 자리를 지키는 것이 `test_옛_값이_한_글자도_안_바뀜` 의 지문(sha256)이다.
|
||||
|
||||
나머지는 열쇠의 약속 — 안 겹침·꼴·두 번 지어도 같음 · **짐작으로 안 이음** ·
|
||||
계층(`parent_mode`)이 그대로 있음 · 총칙 2장을 지우지 않았음(B09 연료·손료가 읽는다).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import OUT_DIR # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import ( # noqa: E402
|
||||
assign_keys,
|
||||
axis_role,
|
||||
empty_registry,
|
||||
assign_variant_keys,
|
||||
format_key,
|
||||
general_provision_roots,
|
||||
load_policy,
|
||||
path_names,
|
||||
toc_edition,
|
||||
variant_display,
|
||||
variant_match,
|
||||
toc_slot,
|
||||
)
|
||||
|
||||
MASTER = OUT_DIR / "work_item_master_2026-01-01.json"
|
||||
|
||||
#: 열쇠를 얹기 **전** 벌(`work_items`)의 지문. 2026-09-17 데스크탑_서브가 뜬 값.
|
||||
#: ⚠ 이 값이 흔들리면 열쇠 작업이 값을 건드렸다는 뜻이다 — 지문을 고치지 말고 원인을 찾을 것.
|
||||
#: 2026-09-17 랩탑 메인 — 목차 오기 둘을 본문 번호로 바로잡아(브레인 지시) **네 줄만** 바뀌어 새로 뜸
|
||||
#: (옛 f8b8fba1…). 네 줄 모습은 `test_목차_오기는_본문_번호로_바로잡고_열쇠는_그대로` 가 잼 ·
|
||||
#: 미판정·밑수 목록 파일은 한 글자도 안 바뀜.
|
||||
#: 2026-09-17 랩탑 메인 둘째 — 헛단위 뺌(인용을 제목으로 읽은 13-3 표 셋 · 부록 사례 번호 겹침 일곱)으로 다시 뜸
|
||||
#: (옛 be88f730…). 바뀐 줄은 13-3·4-1·4-2·2-1·2-2·3-1·3-2·3-3·13-4-1·13-5-2 의 표 귀속뿐 —
|
||||
#: `test_work_item_master_toc` 가 자리를 잼 · 단가표 대조는 그 파일 머리.
|
||||
BASELINE_WORK_ITEMS_SHA = "2accafe5e366c32eb6e95123642b4330e28537b2199dee851247e996fcd839da"
|
||||
|
||||
#: 열쇠 작업이 새로 얹은 칸. 지문을 잴 때만 떼어낸다.
|
||||
ADDED_FIELDS = (
|
||||
"work_item_key",
|
||||
"toc_code",
|
||||
"toc_number",
|
||||
"toc_edition",
|
||||
"path_name",
|
||||
"axis_role",
|
||||
"variants",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def master() -> dict:
|
||||
return json.loads(MASTER.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _fingerprint(items: list[dict]) -> str:
|
||||
stripped = [{k: v for k, v in it.items() if k not in ADDED_FIELDS} for it in items]
|
||||
blob = json.dumps(stripped, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(blob.encode()).hexdigest()
|
||||
|
||||
|
||||
def test_옛_값이_한_글자도_안_바뀜(master: dict) -> None:
|
||||
"""금액을 낳는 값(표·밑수·형태)이 그대로인가 — 새 칸만 떼고 지문을 맞댄다."""
|
||||
assert _fingerprint(master["work_items"]) == BASELINE_WORK_ITEMS_SHA
|
||||
|
||||
|
||||
def test_모든_줄에_열쇠가_있고_안_겹침(master: dict) -> None:
|
||||
items = master["work_items"]
|
||||
keys = [it["work_item_key"] for it in items]
|
||||
assert all(re.fullmatch(r"FW-\d{5}", k) for k in keys)
|
||||
assert len(set(keys)) == len(items)
|
||||
|
||||
|
||||
def test_목차_번호는_칸으로만_남음(master: dict) -> None:
|
||||
"""`FP-…` 는 지우지 않고 「그 판의 목차 번호」 칸으로 남았는가 — 164 파일이 아직 쓴다."""
|
||||
assert master["policy"]["row_key"] == "work_item_key"
|
||||
assert master["policy"]["toc_is_column_not_key"] is True
|
||||
assert master["toc_edition"] == "산림청고시제2025-82호"
|
||||
for it in master["work_items"]:
|
||||
assert it["toc_code"] == it["work_item_code"]
|
||||
assert it["toc_number"] == it["number"]
|
||||
assert it["toc_edition"] == master["toc_edition"]
|
||||
|
||||
|
||||
def test_장부가_있으면_같은_열쇠가_다시_나옴(master: dict) -> None:
|
||||
"""두 번 지어도 같은가 — 장부를 그대로 두고 다시 발급해 본다."""
|
||||
registry = json.loads((OUT_DIR / "work_item_keys.json").read_text(encoding="utf-8"))
|
||||
before = copy.deepcopy(registry)
|
||||
nodes = [
|
||||
{
|
||||
"work_item_code": it["work_item_code"],
|
||||
"number": it["number"],
|
||||
"name": it["name"],
|
||||
"sort_order": it["sort_order"],
|
||||
}
|
||||
for it in master["work_items"]
|
||||
]
|
||||
newly, _ = assign_keys(nodes, registry, edition=master["toc_edition"])
|
||||
assert newly == [] # 다시 발급하지 않는다
|
||||
assert registry["next_serial"] == before["next_serial"]
|
||||
assert [n["work_item_key"] for n in nodes] == [
|
||||
it["work_item_key"] for it in master["work_items"]
|
||||
]
|
||||
|
||||
|
||||
def test_장부에_없는_코드는_새_열쇠를_받음() -> None:
|
||||
"""⚠ 짐작으로 안 이음 — 이름이 같아도 옛 열쇠를 물려주지 않고 새로 내고 목록에 싣는다."""
|
||||
registry = empty_registry()
|
||||
old = [{"work_item_code": "FP-09-03", "number": "9-3", "name": "토사깍기", "sort_order": 256}]
|
||||
assign_keys(old, registry, edition="산림청고시제2025-82호")
|
||||
# 새 판에서 번호가 밀린 같은 이름의 절
|
||||
new = [{"work_item_code": "FP-09-04", "number": "9-4", "name": "토사깍기", "sort_order": 256}]
|
||||
newly, _ = assign_keys(new, registry, edition="산림청고시제9999-99호")
|
||||
assert new[0]["work_item_key"] != old[0]["work_item_key"]
|
||||
assert [n["work_item_key"] for n in newly] == [new[0]["work_item_key"]]
|
||||
|
||||
|
||||
def test_열쇠_차례는_목차_차례() -> None:
|
||||
registry = empty_registry()
|
||||
nodes = [
|
||||
{"work_item_code": "FP-02", "number": "2", "name": "둘", "sort_order": 512},
|
||||
{"work_item_code": "FP-01", "number": "1", "name": "하나", "sort_order": 256},
|
||||
]
|
||||
assign_keys(nodes, registry, edition="e")
|
||||
assert nodes[1]["work_item_key"] == format_key(1)
|
||||
assert nodes[0]["work_item_key"] == format_key(2)
|
||||
|
||||
|
||||
def test_목차_오기는_본문_번호로_바로잡고_열쇠는_그대로(master: dict) -> None:
|
||||
"""⚠ 품셈 목차가 12-17-2·12-24-1 을 두 번 적었음 — 본문은 12-17-3 무근진동기 제외 · 12-27-1 지수판 설치
|
||||
(2026-09-17 코덱스 원문 대조). 목차를 믿으면 뒤 줄이 앞 표를 덮어써 혼성 줄이 서고 F0364·F0375 가 사라짐.
|
||||
열쇠는 같은 공종이라 그대로(장부에 손으로 이음 · `relinked`)."""
|
||||
assert master["toc_duplicate_rows"] == []
|
||||
assert [(c["toc_number"], c["body_number"]) for c in master["toc_corrections"]] == [
|
||||
("12-17-2", "12-17-3"),
|
||||
("12-24-1", "12-27-1"),
|
||||
]
|
||||
by_key = {it["work_item_key"]: it for it in master["work_items"]}
|
||||
for key, number, name, tables in (
|
||||
("FW-00387", "12-17-2", "철근, 펌프카 0-15m", ["F0363"]),
|
||||
("FW-00388", "12-17-3", "무근진동기 제외", ["F0364"]),
|
||||
("FW-00396", "12-24-1", "뒷채움", ["F0371"]),
|
||||
("FW-00401", "12-27-1", "지수판 설치", ["F0375"]),
|
||||
):
|
||||
it = by_key[key]
|
||||
assert (it["number"], it["name"], [t["pum_table_id"] for t in it["tables"]]) == (
|
||||
number,
|
||||
name,
|
||||
tables,
|
||||
)
|
||||
assert not {o["pum_table_id"] for o in master["orphan_tables"]} & {"F0364", "F0375"}
|
||||
|
||||
|
||||
def test_슬롯_이름() -> None:
|
||||
assert toc_slot("FP-12-17-02", 1) == "FP-12-17-02"
|
||||
assert toc_slot("FP-12-17-02", 2) == "FP-12-17-02#2"
|
||||
|
||||
|
||||
def test_잎_경로_이름이_안_겹침(master: dict) -> None:
|
||||
"""「인력」 3곳 · 「수확」 6곳처럼 겹치던 잎 이름이 경로 이름으로 갈리는가."""
|
||||
items = master["work_items"]
|
||||
has_kid = {it.get("parent_code") for it in items}
|
||||
leaves = [it for it in items if it["work_item_code"] not in has_kid and it["tables"]]
|
||||
names = [it["name"] for it in leaves]
|
||||
paths = [it["path_name"] for it in leaves]
|
||||
assert len(set(names)) < len(names) # 이름만으로는 겹친다 — 경로가 필요한 까닭
|
||||
assert len(set(paths)) == len(paths)
|
||||
사람 = next(it for it in items if it["work_item_code"] == "FP-09-03-01")
|
||||
assert 사람["path_name"] == "토공 › 토사깍기 › 인력"
|
||||
|
||||
|
||||
def test_계층과_계산_규칙은_그대로(master: dict) -> None:
|
||||
"""`parent_mode` 가 계산 규칙을 지닌다 — 묶는 마디를 지우지 않았는가."""
|
||||
items = master["work_items"]
|
||||
modes = [it.get("parent_mode") for it in items]
|
||||
assert modes.count("choose_one") == 91
|
||||
assert modes.count("sum_steps") == 3
|
||||
발파암 = next(it for it in items if it["work_item_code"] == "FP-09-05")
|
||||
assert [s["code"] for s in 발파암["steps"]] == ["FP-09-05-01", "FP-09-05-02", "FP-09-05-03"]
|
||||
|
||||
|
||||
def test_총칙은_지우지_않고_구실로_가름(master: dict) -> None:
|
||||
"""⚠ `B09_Estimation_Consumables` 가 총칙 2장 표를 읽는다 — 지우면 연료·손료가 사라진다."""
|
||||
by_code = {it["work_item_code"]: it for it in master["work_items"]}
|
||||
for code, table_id in (
|
||||
("FP-02-01-01", "F0042"), # 체인톱 보통휘발유
|
||||
("FP-02-02-01", "F0064"), # 체인톱 손료계수
|
||||
("FP-02-02-06", "F0069"), # 양수기 손료계수
|
||||
):
|
||||
node = by_code[code]
|
||||
assert node["axis_role"] == "general_provision"
|
||||
assert table_id in [t["pum_table_id"] for t in node["tables"]]
|
||||
roles = master["stats"]["axis_roles"]
|
||||
assert sum(roles.values()) == len(master["work_items"])
|
||||
assert roles["general_provision"] == 78
|
||||
|
||||
|
||||
def test_구실_가름_규칙() -> None:
|
||||
assert (
|
||||
axis_role({"work_item_code": "FP-01-02", "tables": []}, has_children=True)
|
||||
== "general_provision"
|
||||
)
|
||||
assert (
|
||||
axis_role({"work_item_code": "FP-09-03-01", "tables": [{}]}, has_children=False)
|
||||
== "work_item"
|
||||
)
|
||||
assert axis_role({"work_item_code": "FP-09-03", "tables": []}, has_children=True) == "group"
|
||||
assert axis_role({"work_item_code": "FP-09-01", "tables": []}, has_children=False) == "empty"
|
||||
|
||||
|
||||
def test_총칙_범위는_코드가_아니라_자료가_정함() -> None:
|
||||
"""⚠ 뿌리를 코드에 박으면 관리자가 못 고친다 — `axis_policy.json` 이 정본."""
|
||||
assert general_provision_roots("forest") == ("FP-01", "FP-02")
|
||||
const = load_policy("const")
|
||||
assert const["key_prefix"] == "CW"
|
||||
assert const["settled"] is True # 2026-09-17 안티그래비티 조사 · 브레인 확정
|
||||
# 건설 뿌리는 **장 파일 이름**으로 적는다 — 뽑는 코드가 목차 코드로 바꿔 준다(랩탑 메인 `file_place`).
|
||||
# ⚠ 바뀐 코드에는 부문 자리가 들어간다(`CP-01-01`) — 부문마다 장 번호가 1부터 다시 시작해서다.
|
||||
assert const["general_provision_roots"] == [
|
||||
"01_공통부문/제1장_적용기준.md",
|
||||
"01_공통부문/제8장_건설기계.md", # 산림 FP-02(소요재료·기계손료)와 짝
|
||||
]
|
||||
# 유지관리 제1장 「공 통」은 총칙이 아니라 **공종** — 미확정 목록이 비었다
|
||||
assert const["general_provision_pending"] == []
|
||||
# 뿌리를 바꿔 주면 구실도 따라 바뀐다 — 잣대가 자료에 있다는 증거
|
||||
assert (
|
||||
axis_role({"work_item_code": "FP-09-03", "tables": []}, has_children=True, roots=("FP-09",))
|
||||
== "general_provision"
|
||||
)
|
||||
|
||||
|
||||
def test_건설_총칙_뿌리가_실제로_붙음() -> None:
|
||||
"""잣대가 건설 산출에 닿았는가 — 뿌리 아래 37줄이 `general_provision` 으로 섰다."""
|
||||
doc = json.loads(
|
||||
(OUT_DIR / "const_work_item_master_2026-01-01.json").read_text(encoding="utf-8")
|
||||
)
|
||||
roles = doc["stats"]["axis_roles"]
|
||||
assert roles["general_provision"] == 112 # 적용기준 37 + 건설기계 75
|
||||
assert sum(roles.values()) == len(doc["work_items"]) == 1367
|
||||
for root, count in (("CP-01-01", 37), ("CP-01-08", 75)):
|
||||
under = [
|
||||
it
|
||||
for it in doc["work_items"]
|
||||
if it["work_item_code"] == root or it["work_item_code"].startswith(root + "-")
|
||||
]
|
||||
assert len(under) == count
|
||||
assert {it["axis_role"] for it in under} == {"general_provision"}
|
||||
# 유지관리 제1장 「공 통」은 공종으로 남는다(총칙 아님)
|
||||
공통 = next(it for it in doc["work_items"] if it["work_item_code"] == "CP-05-01")
|
||||
assert 공통["axis_role"] != "general_provision"
|
||||
|
||||
|
||||
def test_갈래에도_불변_열쇠가_있다(master: dict) -> None:
|
||||
"""갈래 열쇠는 `FW-00105#01` — **글자가 아니라 번호**다(2026-09-17 브레인).
|
||||
|
||||
글자를 열쇠에 담으면 원문 자간·물결표가 다듬어지는 날 열쇠가 갈린다. 공종 열쇠와 같은 병이다.
|
||||
"""
|
||||
items = master["work_items"]
|
||||
keys, pairs = [], []
|
||||
for it in items:
|
||||
for v in it.get("variants") or []:
|
||||
assert re.fullmatch(r"\d{2}", v["variant_key"]), v
|
||||
keys.append(f"{it['work_item_key']}#{v['variant_key']}")
|
||||
pairs.append((v["name"], v["name_clean"]))
|
||||
assert len(keys) == 332 # 갈래 수 — 헛단위 13-3 갈래 3 뺌(335 → 332 · 2026-09-17 · 장부 번호는 남음)
|
||||
assert len(set(keys)) == len(keys) # 겹침 0
|
||||
# 한 공종 안에서 번호는 1부터 빠짐없이
|
||||
for it in items:
|
||||
got = [v["variant_key"] for v in it.get("variants") or []]
|
||||
assert got == [f"{i + 1:02d}" for i in range(len(got))], it["work_item_key"]
|
||||
# 이름 칸 둘 — 원문은 손대지 않고, 다듬은 것이 따로
|
||||
raw = [it.get("variant_keys") or [] for it in items]
|
||||
assert [n for it in items for n in (it.get("variant_keys") or [])] == [p[0] for p in pairs]
|
||||
# 자간·물결표가 다듬어진 줄 — 14 → 70(2026-09-17 한 글자 낱말 자간 붙이기 「메 붙 임」→「메붙임」 · 열쇠는 그대로)
|
||||
assert sum(1 for a, b in pairs if a != b) == 70
|
||||
assert raw # 원문 목록은 그대로 남아 있다
|
||||
|
||||
|
||||
def test_갈래_열쇠는_글자가_바뀌어도_안_흔들린다() -> None:
|
||||
"""맞대는 꼴이 공백·물결표를 흡수한다 — 이름 다듬기가 들어와도 번호가 그대로다."""
|
||||
reg = empty_registry()
|
||||
node = {
|
||||
"work_item_key": "FW-00105",
|
||||
"sort_order": 1,
|
||||
"variant_keys": ["1.1∼1.5", "간 단"],
|
||||
}
|
||||
assign_variant_keys([node], reg)
|
||||
before = [v["variant_key"] for v in node["variants"]]
|
||||
# 원문을 다듬어도(물결표 바꿈 · 자간 정리) 같은 번호가 나와야 한다
|
||||
node2 = {"work_item_key": "FW-00105", "sort_order": 1, "variant_keys": ["1.1~1.5", "간 단"]}
|
||||
newly = assign_variant_keys([node2], reg)
|
||||
assert [v["variant_key"] for v in node2["variants"]] == before
|
||||
assert newly == [] # 새 번호를 내지 않는다
|
||||
assert variant_match("간 단") == variant_match("간 단") == "간단"
|
||||
assert variant_display("직경 60㎝이상 ~80㎝미만") == "직경 60㎝이상 ∼80㎝미만"
|
||||
|
||||
|
||||
def test_갈래_문자열이_열쇠라_흔들림을_못박음(master: dict) -> None:
|
||||
"""⚠ 갈래가 줄 열쇠에 그대로 들어간다(`@id` = `FW-00105#1.1∼1.5`).
|
||||
|
||||
품셈 원문이 물결표를 두 가지로 쓰고 자간 공백도 들쭉날쭉이라, 문자열을 손질하면 **열쇠가 갈린다.**
|
||||
지금 몇 개가 그런지 못박아 둔다 — 늘면 손질이 들어간 것이고, 줄면 열쇠가 바뀐 것이다.
|
||||
"""
|
||||
import collections
|
||||
|
||||
keys = [k for it in master["work_items"] for k in (it.get("variant_keys") or [])]
|
||||
assert len(keys) == 332 # 2026-09-17 헛단위 13-3 갈래 3 뺌(335 → 332)
|
||||
tilde = collections.Counter(ch for k in keys for ch in k if ch in "∼~")
|
||||
assert tilde == {"∼": 26, "~": 8}, "물결표 쓰임이 바뀜 — 열쇠가 갈렸는지 볼 것"
|
||||
squeezed = collections.defaultdict(set)
|
||||
for k in keys:
|
||||
squeezed["".join(k.split()).replace("~", "∼")].add(k)
|
||||
messy = {n: v for n, v in squeezed.items() if len(v) > 1}
|
||||
assert len(messy) == 6, f"공백·물결표만 다른 짝이 {len(messy)} — 6 이던 것"
|
||||
|
||||
|
||||
def test_어느_구실도_지우지_않음() -> None:
|
||||
"""브레인 승인(2026-09-17) — 「덜어내기」가 아니라 「가름만」."""
|
||||
import json as _json
|
||||
from pathlib import Path as _Path
|
||||
|
||||
policy = _json.loads(
|
||||
_Path("resources/data_work_item_master/axis_policy.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert policy["policy"]["never_delete"] is True
|
||||
assert policy["policy"]["flat_list_filter"] == "pum_tables > 0"
|
||||
assert all(role["keep"] for role in policy["roles"].values())
|
||||
assert list(policy["roles"]) == ["work_item", "group", "general_provision", "empty"]
|
||||
|
||||
|
||||
def test_경로_이름_잇기() -> None:
|
||||
nodes = [
|
||||
{"work_item_code": "A", "parent_code": None, "name": "토공"},
|
||||
{"work_item_code": "A-1", "parent_code": "A", "name": "토사깍기"},
|
||||
{"work_item_code": "A-1-1", "parent_code": "A-1", "name": "인력"},
|
||||
]
|
||||
assert path_names(nodes)[2] == "토공 › 토사깍기 › 인력"
|
||||
|
||||
|
||||
def test_고시_번호를_원문_경로에서_읽음() -> None:
|
||||
sources = [
|
||||
{"path": "resources/knowledge/original/…/(산림청고시 제2025-82호) 산림사업 표준품셈.md"}
|
||||
]
|
||||
assert toc_edition(sources, "2026-01-01") == "산림청고시제2025-82호"
|
||||
assert toc_edition([], "2026-01-01") == "2026-01-01"
|
||||
@@ -0,0 +1,138 @@
|
||||
"""연결고리 표 소비 — 산림·건설 어느 품을 쓸지 **표가 정함** (2026-09-17 브레인 · 코덱스 크로스체크 ②).
|
||||
|
||||
고시 총칙 7 「유사 공종은 본 품셈 우선」 이 규칙으로만 있고 아무도 안 읽던 자리. 못박는 것:
|
||||
① 둘 다 있는 공종은 표의 `both_precedence` 쪽 — 표를 바꾸면 고르는 쪽도 바뀜(코드에 안 박힘)
|
||||
② 미판정은 안 고르고 막음 · 사유 그대로 · 금액에 안 듦
|
||||
③ 지금 인계 코드는 한 줄도 판정이 안 달라짐 = **금액 불변**(이행 전 금액은 `test_work_item_key_gate` 가 못박음)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities_Input import (
|
||||
BLOCKED_LINK_UNDECIDED,
|
||||
parse_handoff,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices
|
||||
from common_util import common_util_work_item_link as link
|
||||
from common_util.common_util_work_item_key import MASTER_DIR, work_item_code_of, work_item_key
|
||||
|
||||
MAPPING = MASTER_DIR.parent / "data_work_item_mapping" / "work_item_mapping_2026-01-01.json"
|
||||
|
||||
|
||||
def _table(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, links: list, precedence="forest"
|
||||
) -> None:
|
||||
folder = tmp_path / "data_work_item_link"
|
||||
folder.mkdir(parents=True)
|
||||
doc = {
|
||||
"policy": {"both_precedence": precedence, "unconfirmed_action": "do_not_link"},
|
||||
"links": links,
|
||||
}
|
||||
(folder / "work_item_link_2026-01-01.json").write_text(
|
||||
json.dumps(doc, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(link, "FOLDER", folder)
|
||||
|
||||
|
||||
def test_지금_인계_코드는_판정이_안_달라짐_금액_불변() -> None:
|
||||
codes = set(
|
||||
re.findall(r'"((?:FP|AX-WK|AX-ST)-[0-9A-Za-z-]+)"', MAPPING.read_text(encoding="utf-8"))
|
||||
)
|
||||
changed = []
|
||||
for code in sorted(codes):
|
||||
key = work_item_key(code, "2026-01-01")
|
||||
choice = link.choose(key)
|
||||
if choice.key != key or choice.blocked:
|
||||
changed.append((code, key, choice))
|
||||
assert changed == [] # 달라지면 그동안 잘못 고르고 있었다는 뜻 — 브레인에 먼저 알릴 것
|
||||
|
||||
|
||||
def test_둘_다_있으면_표가_적은_쪽() -> None:
|
||||
"""실제 표 — 산림 9-7-1 무근콘크리트 깨기 ↔ 건설 8-2-13 대형브레이커(E 0.35 ↔ 0.45)."""
|
||||
assert link.choose("CW-00330").key == "FW-00260"
|
||||
assert link.choose("FW-00260").key == "FW-00260"
|
||||
assert link.choose("CW-00844").key == "CW-00844" # 건설에만(모르타르 배합) — 그대로
|
||||
assert link.choose("FW-00249").key == "FW-00249" # 표에 없음 — 제 품셈
|
||||
|
||||
|
||||
def test_우선_품셈은_코드가_아니라_표가_정함(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
rows = [{"forest_key": "FW-00260", "const_key": "CW-00330", "branch": "both", "status": "확정"}]
|
||||
_table(tmp_path, monkeypatch, rows, precedence="construction")
|
||||
assert link.choose("FW-00260").key == "CW-00330"
|
||||
_table(tmp_path / "b", monkeypatch, rows, precedence="")
|
||||
assert link.choose("FW-00260").key is None and link.choose("FW-00260").blocked
|
||||
|
||||
|
||||
def test_미판정은_안_고르고_막음_금액에_안_듦(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
payload = {
|
||||
"work_items": [
|
||||
{"work_item_code": "FP-09-03-02", "name": "토사깍기", "unit": "㎥", "quantity": 100},
|
||||
{"work_item_code": "FP-09-12-01", "name": "측구터파기", "unit": "㎥", "quantity": 7},
|
||||
],
|
||||
"materials": [],
|
||||
}
|
||||
build = build_unit_prices()
|
||||
before = build_bill(json.loads(json.dumps(payload)), build=build)
|
||||
ditch = work_item_key("FP-09-12-01", "2026-01-01")
|
||||
rows = [
|
||||
{
|
||||
"forest_key": ditch,
|
||||
"const_key": "",
|
||||
"branch": "both",
|
||||
"status": "미판정",
|
||||
"evidence": "시험 — 측구 터파기 건설 대응 미확인",
|
||||
}
|
||||
]
|
||||
_table(tmp_path, monkeypatch, rows)
|
||||
items, _ = parse_handoff(json.loads(json.dumps(payload)))
|
||||
assert (
|
||||
items[1].blocked_kind == BLOCKED_LINK_UNDECIDED and "시험 — 측구" in items[1].blocked_reason
|
||||
)
|
||||
after = build_bill(json.loads(json.dumps(payload)), build=build)
|
||||
blocked = [m for m in after.missing if m.get("blocked_kind") == BLOCKED_LINK_UNDECIDED]
|
||||
assert [m["name"] for m in blocked] == ["측구터파기"]
|
||||
ditch_amount = sum(
|
||||
r.amount_krw or 0 for r in before.rows if not r.is_group and r.name == "측구터파기"
|
||||
)
|
||||
assert ditch_amount > 0 and after.body_total_krw == before.body_total_krw - ditch_amount
|
||||
assert bill_summary(after)["link_undecided"][0]["evidence"].startswith("시험")
|
||||
|
||||
|
||||
def test_건설_열쇠로_온_줄은_표가_고른_산림_열쇠와_목차_코드로() -> None:
|
||||
const_code = work_item_code_of("CW-00330")
|
||||
assert const_code and const_code.startswith("CP-")
|
||||
items, _ = parse_handoff(
|
||||
{
|
||||
"work_items": [
|
||||
{
|
||||
"work_item_code": const_code,
|
||||
"name": "대형브레이커",
|
||||
"unit": "㎥",
|
||||
"quantity": 1,
|
||||
"pum_edition": "2026-01-01",
|
||||
}
|
||||
],
|
||||
"materials": [],
|
||||
}
|
||||
)
|
||||
assert (items[0].work_item_key, items[0].work_item_code) == (
|
||||
"FW-00260",
|
||||
work_item_code_of("FW-00260"),
|
||||
)
|
||||
assert items[0].work_item_code.startswith("FP-")
|
||||
|
||||
|
||||
def test_미판정_행은_열쇠가_비어도_목록으로_드러남() -> None:
|
||||
undecided = link.undecided_links()
|
||||
assert undecided and all(row["evidence"] for row in undecided)
|
||||
@@ -0,0 +1,191 @@
|
||||
"""건설 공종 축(`CW-00001`) 뽑기 — 2026-09-17 브레인 8-2.
|
||||
|
||||
못박는 것
|
||||
① 목차 글줄 가르기 — 쪽 번호가 다음 번호에 붙은 꼴(「131-2-6」 = 13쪽 + 1-2-6) · 부문마다 장 번호 1부터
|
||||
② 표는 **본문 절 제목**으로 붙음 — 인용(「3-2-4 터파기(기계)’를 참고하여」)은 제목이 아님
|
||||
③ 목차 오기 없음 — 연혁 표시(「('20년 보완)」)가 붙은 본문 제목이 모두 목차 번호·이름과 같음
|
||||
(산림에서 목차가 번호를 잘못 적어 표가 딴 줄에 붙은 병 · 건설도 같은 함정)
|
||||
④ 못 붙인 표는 목록으로 · 열쇠 CW- 안 겹침 · 다시 뽑아도 같은 열쇠
|
||||
⑤ 산출 파일 = 지금 코드로 뽑은 것(산림도 같이 — 두 뽑기가 표 읽기 한 벌을 나눠 씀 · 금액 불변)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from B08_Quantity import B08_Quantity_Build_WorkItemMaster as forest
|
||||
from B08_Quantity import B08_Quantity_Build_WorkItemMaster_Const as const
|
||||
|
||||
OUT = forest.OUT_DIR
|
||||
CONST_MASTER = OUT / "const_work_item_master_2026-01-01.json"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def built() -> tuple:
|
||||
return const.build_const()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def master() -> dict:
|
||||
return json.loads(CONST_MASTER.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _same_except_time(fresh: dict, saved: dict) -> None:
|
||||
fresh = json.loads(json.dumps(fresh, ensure_ascii=False))
|
||||
for doc in (fresh, saved):
|
||||
doc.pop("generated_at")
|
||||
doc["stats"].pop("keys_newly_issued") # 첫 뽑기만 발급 · 다시 뽑으면 0
|
||||
doc["stats"].pop("variant_keys_newly_issued", None) # 갈래 열쇠도 같음
|
||||
assert fresh == saved
|
||||
|
||||
|
||||
def test_목차_글줄_가르기_쪽_번호가_붙은_꼴() -> None:
|
||||
text = "제1장적용기준31-1 일반사항····31-1-1 목적····131-2-6 공구 및 경장비····19제2장가설공사352-1 가설물의 한도 ····35"
|
||||
assert const.toc_entries(text) == [
|
||||
("1", "1", "적용기준"),
|
||||
("1", "1-1", "일반사항"),
|
||||
("1", "1-1-1", "목적"),
|
||||
("1", "1-2-6", "공구 및 경장비"),
|
||||
("2", "2", "가설공사"),
|
||||
("2", "2-1", "가설물의 한도"),
|
||||
]
|
||||
assert const.toc_entries("제11장칠공사61111-1 공통사항····611")[1] == ("11", "11-1", "공통사항")
|
||||
|
||||
|
||||
def test_인용은_제목이_아님() -> None:
|
||||
names = {("공통부문", "3-2-4"): "터파기(기계)", ("공통부문", "3-2-5"): "되메우기"}
|
||||
chapters = {"2", "3", "4"}
|
||||
assert (
|
||||
const.titles_in("’3-2-4 터파기(기계)’를 참고하여 적용한다.", "공통부문", chapters, names)
|
||||
== []
|
||||
)
|
||||
assert const.titles_in("…경우3-2-4 터파기(기계)('25년 신설)", "공통부문", chapters, names) == [
|
||||
"3-2-4"
|
||||
]
|
||||
assert const.titles_in("853-2-5 되메우기Q = n·q", "공통부문", chapters, names) == ["3-2-5"]
|
||||
|
||||
|
||||
def test_목차_부문_다섯_장_마흔다섯(built: tuple) -> None:
|
||||
items = built[0]["work_items"]
|
||||
roots = [it for it in items if it["parent_code"] is None]
|
||||
assert [r["name"] for r in roots] == list(const.DIVISIONS)
|
||||
assert sum(1 for it in items if it["level"] == 2) == 45
|
||||
codes = [it["work_item_code"] for it in items]
|
||||
assert len(set(codes)) == len(codes)
|
||||
|
||||
|
||||
def test_표는_다_붙고_못_붙인_표는_목록(built: tuple) -> None:
|
||||
master = built[0]
|
||||
stats = master["stats"]
|
||||
assert stats["tables_total"] == 2192
|
||||
assert stats["tables_attached"] + len(master["orphan_tables"]) == stats["tables_total"]
|
||||
assert stats["tables_orphan"] == len(master["orphan_tables"]) == 0
|
||||
|
||||
|
||||
def test_목차_오기_없음_연혁_붙은_본문_제목이_목차와_같음(built: tuple) -> None:
|
||||
"""본문 제목 번호가 목차에 없거나 이름이 다르면 그 절 표가 **앞 절에 조용히 붙음** — 빨강."""
|
||||
items = built[0]["work_items"]
|
||||
names = {(it["division"], it["number"]): it["name"] for it in items if it["level"] > 1}
|
||||
titled = re.compile(r"(\d+(?:-\d+)+)\s*([^’'\[\]()]{1,40}?)\s*\(\s*['‘’]\d\d")
|
||||
data = json.loads(const.SOURCE.read_text(encoding="utf-8"))
|
||||
files = sorted({t["source_file"] for t in data["variables"]["pum"]["tables"]})
|
||||
seen, bad = 0, []
|
||||
for source_file in files:
|
||||
division, chapter = const.file_place(source_file)
|
||||
chapters = {str(chapter - 1), str(chapter), str(chapter + 1)}
|
||||
for line in (const.ROOT / source_file).read_text(encoding="utf-8").splitlines():
|
||||
for found in titled.finditer(line):
|
||||
raw, name = found.group(1), "".join(found.group(2).split())
|
||||
number = next(
|
||||
(
|
||||
raw[cut:]
|
||||
for cut in range(len(raw.split("-")[0]))
|
||||
if raw[cut:].split("-")[0] in chapters
|
||||
),
|
||||
None,
|
||||
)
|
||||
if number is None:
|
||||
continue
|
||||
seen += 1
|
||||
toc = "".join(names.get((division, number), "").split())
|
||||
if not toc or not (name.startswith(toc) or toc.startswith(name)):
|
||||
bad.append(
|
||||
f"{division} {number} 「{found.group(2)}」 목차 「{names.get((division, number))}」"
|
||||
)
|
||||
assert seen > 600
|
||||
assert not bad, bad
|
||||
|
||||
|
||||
def test_열쇠_CW_안_겹침_다시_뽑아도_같음(built: tuple, master: dict) -> None:
|
||||
keys = [it["work_item_key"] for it in master["work_items"]]
|
||||
assert all(re.fullmatch(r"CW-\d{5}", k) for k in keys)
|
||||
assert len(set(keys)) == len(keys)
|
||||
assert built[3][1] == [] # 장부가 있으니 새로 안 냄
|
||||
assert [it["work_item_key"] for it in built[0]["work_items"]] == keys
|
||||
|
||||
|
||||
def test_산출_파일이_지금_코드로_뽑은_것(built: tuple, master: dict) -> None:
|
||||
_same_except_time(built[0], master)
|
||||
|
||||
|
||||
def test_산림_산출도_그대로_금액_불변() -> None:
|
||||
"""표 읽기를 떼어내고(`_Table`) 건설과 나눠 써도 산림 산출은 한 글자도 안 바뀜."""
|
||||
saved = json.loads((OUT / "work_item_master_2026-01-01.json").read_text(encoding="utf-8"))
|
||||
master, undetermined, basis_missing, _ = forest.build()
|
||||
_same_except_time(master, saved)
|
||||
for name, fresh in (("form_undetermined", undetermined), ("basis_missing", basis_missing)):
|
||||
saved_items = json.loads((OUT / f"{name}_2026-01-01.json").read_text(encoding="utf-8"))[
|
||||
"items"
|
||||
]
|
||||
assert json.loads(json.dumps(fresh, ensure_ascii=False)) == saved_items
|
||||
|
||||
|
||||
def test_산림을_고르는_쪽이_건설_파일을_안_집음() -> None:
|
||||
"""⚠ `work_item_master_*` 로 끝 파일을 고르는 자리가 있음(B08 밑수 대조 · B09 근거표) —
|
||||
건설 산출을 `work_item_master_const_…` 로 두면 그쪽이 건설을 집어 밑수 대조가 통째로 빠졌음(2026-09-17)."""
|
||||
from B08_Quantity.B08_Quantity_Engine_BasisUnit import load_master
|
||||
|
||||
assert load_master()["dataset_id"] == "work_item_master_forest"
|
||||
assert not list(OUT.glob("work_item_master_*const*.json"))
|
||||
|
||||
|
||||
def test_건설_갈래도_이름을_다듬어도_같은_열쇠(master: dict) -> None:
|
||||
"""④ 건설 갈래 펴기(2026-09-17) — 갈래 열쇠는 번호(`CW-00063#01`) · 자간·물결표를 손질해도 새로 안 남."""
|
||||
import copy
|
||||
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import (
|
||||
assign_variant_keys,
|
||||
load_registry,
|
||||
)
|
||||
|
||||
items = master["work_items"]
|
||||
spread = [it for it in items if it.get("variants")]
|
||||
assert len(spread) > 100 and sum(len(it["variants"]) for it in spread) > 400
|
||||
registry = load_registry(const.CONST_KEY_REGISTRY, const.KEY_PREFIX)
|
||||
nodes = copy.deepcopy(spread)
|
||||
for node in nodes: # 사람이 이름을 다듬은 날 — 자간 한 칸 · 물결표 다른 벌
|
||||
node["variant_keys"] = [
|
||||
" ".join(k.split()).replace("∼", "~") for k in node["variant_keys"]
|
||||
]
|
||||
assert assign_variant_keys(nodes, registry) == [] # 새 번호 0
|
||||
for before, after in zip(spread, nodes):
|
||||
assert [v["variant_key"] for v in after["variants"]] == [
|
||||
v["variant_key"] for v in before["variants"]
|
||||
]
|
||||
|
||||
|
||||
def test_자간_다듬기는_진짜_띄어쓰기를_안_붙임() -> None:
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import tidy_spacing
|
||||
|
||||
assert tidy_spacing("측 량") == "측량"
|
||||
assert tidy_spacing("평 규 준 틀") == "평규준틀"
|
||||
assert tidy_spacing("굴 삭 기 (무한궤도)") == "굴삭기 (무한궤도)"
|
||||
assert tidy_spacing("메 붙 임 · 깬 돌 · 뒷길이 25㎝") == "메붙임 · 깬돌 · 뒷길이 25㎝"
|
||||
assert tidy_spacing("토목부문 › 측 량 › 기준점 측량") == "토목부문 › 측량 › 기준점 측량"
|
||||
assert (
|
||||
tidy_spacing("그 외 자재의 운반품셈") == "그 외 자재의 운반품셈"
|
||||
) # 한 글자 낱말 둘은 진짜 띄어쓰기
|
||||
assert tidy_spacing("호박돌 및 야면석 · 뒷길이 25㎝") == "호박돌 및 야면석 · 뒷길이 25㎝"
|
||||
@@ -0,0 +1,100 @@
|
||||
"""공종 축 — **이름과 붙은 표의 절이 어긋나면 빨강** (2026-09-17 브레인 · 코덱스 원문 대조).
|
||||
|
||||
이 병은 한 번 나면 아무도 못 알아챔: 품셈 목차가 같은 번호를 두 번 적자(12-17-2 · 12-24-1)
|
||||
뒤 줄(「무근진동기 제외」)이 앞 표(F0363 「철근, 펌프카 0-15m」)를 물고, 진짜 표(F0364)는 orphan 으로 사라졌음.
|
||||
표 번호로는 안 잡힘(번호는 같았음) — **표 절 제목의 이름이 다른 줄 이름과 똑같은데 그 줄이 아닌 데 붙음**이 그 꼴.
|
||||
⚠ 이름 글자만 조금 다른 자리(목차 「깍기」 · 본문 「깎기」)는 판정하지 않음 — 번호·이름이 딴 줄을 가리킬 때만.
|
||||
⚠ 2026-09-17 둘째 — **헛단위**(목록에 없는 공종이 섬 · 서브 훑기 · 브레인):
|
||||
· [주] 인용 「‘13-3. 기초다짐 및 뒤채움’ 항을 적용한다」를 제목으로 읽어 표 셋(F0405·F0406·F0418)이 13-3 에 붙음
|
||||
· 부록 사례 표 일곱(F0455·F0456·F0472~F0476)이 본문 절 번호와 겹쳐 4-1·4-2 따위에 붙음
|
||||
⇒ 인용은 제목이 아님 · 번호만 겹치고 이름이 딴판이면 못 붙인 표 목록으로.
|
||||
단가표 대조(677 제목 전후) — 헛단위 B-FP-13-03 셋이 빠지고 **B-FP-13-05-02 가 메붙임+찰붙임 합으로 약 두 배**
|
||||
(쓰는 곳 0 · `KnownGaps` 에 「쓰면 안 됨」 표시 · 갈래 가르기 대기). 나머지 단가 한 원도 안 바뀜.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MASTER = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json"
|
||||
_TITLE = re.compile(r"^\s*(\d+(?:-\d+){0,3})\.\s+(.+)$")
|
||||
|
||||
|
||||
def _squeeze(text: str) -> str:
|
||||
return "".join(str(text).split())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def master() -> dict:
|
||||
return json.loads(MASTER.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_목차_번호가_안_겹침(master: dict) -> None:
|
||||
numbers = [it["number"] for it in master["work_items"]]
|
||||
assert len(set(numbers)) == len(numbers)
|
||||
|
||||
|
||||
def test_표_절_제목이_다른_줄을_가리키지_않음(master: dict) -> None:
|
||||
items = master["work_items"]
|
||||
by_name: dict[str, set[str]] = {} # 이름 → 그 이름 줄의 열쇠(번호가 겹친 줄도 갈림)
|
||||
for it in items:
|
||||
by_name.setdefault(_squeeze(it["name"]), set()).add(it["work_item_key"])
|
||||
bad = []
|
||||
for it in items:
|
||||
for table in it["tables"]:
|
||||
title = _TITLE.match(table["section"])
|
||||
if title is None:
|
||||
continue
|
||||
number, name = title.group(1), _squeeze(title.group(2))
|
||||
others = by_name.get(name, set()) - {it["work_item_key"]}
|
||||
if number != it["number"] or (others and name != _squeeze(it["name"])):
|
||||
bad.append(
|
||||
f"{table['pum_table_id']} 「{table['section']}」 → {it['number']} {it['name']}"
|
||||
)
|
||||
assert not bad, bad
|
||||
|
||||
|
||||
def test_사라진_표는_목록으로_남음(master: dict) -> None:
|
||||
"""orphan 을 조용히 버리지 않음 — 목록 수와 집계가 같아야 함."""
|
||||
assert master["stats"]["tables_orphan"] == len(master["orphan_tables"])
|
||||
assert (
|
||||
master["stats"]["tables_attached"] + len(master["orphan_tables"])
|
||||
== master["stats"]["tables_total"]
|
||||
)
|
||||
|
||||
|
||||
APPENDIX = {"F0455", "F0456", "F0472", "F0473", "F0474", "F0475", "F0476"}
|
||||
|
||||
|
||||
def test_인용은_제목이_아니라_표가_제_절에_붙음(master: dict) -> None:
|
||||
by_code = {it["work_item_code"]: it for it in master["work_items"]}
|
||||
tables = lambda code: [t["pum_table_id"] for t in by_code[code]["tables"]] # noqa: E731
|
||||
assert tables("FP-13-03") == [] # 헛단위 — 표 없는 묶는 마디로
|
||||
assert {"F0405", "F0406"} <= set(tables("FP-13-04-01"))
|
||||
assert "F0418" in tables("FP-13-05-02")
|
||||
|
||||
|
||||
def test_부록_사례_표는_번호가_겹쳐도_안_붙고_목록으로(master: dict) -> None:
|
||||
attached = {t["pum_table_id"] for it in master["work_items"] for t in it["tables"]}
|
||||
assert not attached & APPENDIX
|
||||
assert APPENDIX <= {o["pum_table_id"] for o in master["orphan_tables"]}
|
||||
by_code = {it["work_item_code"]: it for it in master["work_items"]}
|
||||
assert by_code["FP-04-01"]["tables"] == [] and by_code["FP-04-02"]["tables"] == []
|
||||
|
||||
|
||||
def test_이름_닮음_잣대() -> None:
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import _is_section_title, title_names_node
|
||||
|
||||
assert not _is_section_title("13-3. 기초다짐 및 뒤채움’ 항을 적용한다.", {})
|
||||
assert _is_section_title(
|
||||
"2-9. 어린나무가꾸기, (1) 치수림단계 (제거대상 피복도 ‘소’, 가지치기)", {}
|
||||
)
|
||||
assert not title_names_node("4-1. 소나무재선충병방제", {"name": "수확베기"})
|
||||
assert title_names_node("9-19-2. 비탈면 고르기(암절취)", {"name": "비탈면 면고르기(암절취)"})
|
||||
assert title_names_node("12-13. 면벽", {"name": "면벅"})
|
||||
assert title_names_node("9-7-1. T=30㎝ 미만", {"name": "T=30cm 미만"})
|
||||
@@ -23,6 +23,7 @@ from fastapi.testclient import TestClient
|
||||
from Z01_MasterData import Z01_MasterData_BasePrices as base_prices
|
||||
from Z01_MasterData import Z01_MasterData_Overrides as overrides
|
||||
from Z01_MasterData import Z01_MasterData_Router as router_module
|
||||
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
|
||||
|
||||
NEW_KINDS = (
|
||||
"coef",
|
||||
@@ -60,7 +61,10 @@ def _ids(table: dict) -> list[str]:
|
||||
|
||||
|
||||
def test_아홉이_기초단가와_같은_길로_섬(client: TestClient) -> None:
|
||||
assert base_prices.KINDS == ("labor", "machine", "material", "oil", "rate") + NEW_KINDS
|
||||
assert (
|
||||
base_prices.KINDS
|
||||
== ("labor", "machine", "material", "oil", "rate") + NEW_KINDS + work_items.KINDS
|
||||
)
|
||||
for kind in NEW_KINDS:
|
||||
table = _get(client, kind, size=500)
|
||||
assert table["total"] == len(table["rows"]) > 0, kind
|
||||
@@ -81,6 +85,9 @@ def test_돌쌓기_경사는_도면에도_닿는다고_알림(client: TestClient
|
||||
"""아홉 중 하나만 수량 말고 **그림**에도 닿음 — 화면이 사전을 가지면 문구가 두 벌(브레인)."""
|
||||
slope = _get(client, "masonry_slope", size=1)["notice"]
|
||||
assert any("횡단 도면" in line for line in slope) and len(slope) == 2
|
||||
# ⚠ 지금 상태를 먼저 — 덮개를 읽는 곳이 Z01 밖에 0(2026-09-17 브레인 「없으면 없다고」)
|
||||
assert any(line.startswith("⚠ **B06 횡단설계가 아직") for line in slope)
|
||||
assert slope[0].startswith("⚠ **아직 어디에도 안 쓰임**")
|
||||
assert all(
|
||||
not any("횡단 도면" in line for line in _get(client, kind, size=1)["notice"])
|
||||
for kind in NEW_KINDS
|
||||
@@ -286,7 +293,7 @@ def test_kind_목록을_서버가_줌_새_kind_도_저절로(
|
||||
assert [i["kind"] for i in items] == list(base_prices.KINDS)
|
||||
groups = {i["kind"]: i["group"] for i in items}
|
||||
assert groups["labor"] == "base_price" and groups["machine_productivity"] == "pumsem_basis"
|
||||
assert {i["group"] for i in items} == {"base_price", "pumsem_basis"}
|
||||
assert {i["group"] for i in items} == {"base_price", "pumsem_basis", "work_item"}
|
||||
labor = next(i for i in items if i["kind"] == "labor")
|
||||
assert labor["label"] == "노임" and labor["rows"] == 261 # 이름은 이름표에서
|
||||
assert next(i for i in items if i["kind"] == "machine_productivity")["rows"] == 47
|
||||
|
||||
@@ -25,19 +25,30 @@ _RUNNER = """
|
||||
import { writeFileSync } from "node:fs";
|
||||
import {
|
||||
cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells,
|
||||
boldSegments, groupKinds, nextSort, noticeLines, outdatedBanner, shownColumns, valueWithUnit,
|
||||
boldSegments, filterValue, groupKinds, nextSort, noticeLines, outdatedBanner, shownColumns, valueWithUnit,
|
||||
} from "./Z01_MasterData_UI_Cells.js";
|
||||
|
||||
// kind 목록은 서버가 줌 — 화면이 제 목록을 들면 kind 가 늘 때마다 빠짐(2026-09-16 브레인)
|
||||
const served = [
|
||||
{ group: "base_price", kind: "labor", label: "노임", rows: 261 },
|
||||
{ group: "base_price", kind: "oil", label: "유가", rows: 36 },
|
||||
{ group: "pumsem_basis", kind: "coef", label: "토량환산계수", rows: 32 },
|
||||
{ group: "base_price", group_label: "기초단가", kind: "labor", label: "노임", rows: 261 },
|
||||
{ group: "base_price", group_label: "기초단가", kind: "oil", label: "유가", rows: 36 },
|
||||
{ group: "pumsem_basis", group_label: "품셈 기준", kind: "coef", label: "토량환산계수", rows: 32 },
|
||||
// 서버가 새 kind·새 갈래를 더해도 화면이 그대로 세움
|
||||
{ group: "pumsem_basis", kind: "machine_productivity", label: "기계 작업량", rows: 47 },
|
||||
{ group: "pumsem_basis", group_label: "품셈 기준", kind: "machine_productivity", label: "기계 작업량", rows: 47 },
|
||||
{ group: "work_axis", group_label: "공종 축", kind: "forest", label: "산림품셈 기준", rows: 358 },
|
||||
// 갈래 이름이 안 오면 키가 그대로 떠 빠진 것이 드러남(때우지 않음)
|
||||
{ group: "later_group", kind: "brand_new", label: "새 갈래 것", rows: 3 },
|
||||
];
|
||||
const grouped = groupKinds(served).map((g) => [g.group, g.items.map((i) => i.kind)]);
|
||||
const grouped = groupKinds(served).map((g) => [g.label, g.items.map((i) => i.kind)]);
|
||||
|
||||
// 거르기 — 안 골랐으면 서버가 이미 건 기본값을 보임(보이는 것 = 걸린 것) · 고르면 고른 값
|
||||
const roadFilter = {
|
||||
key: "use",
|
||||
label: "보기",
|
||||
options: [{ value: "all", label: "전부", rows: 995 }, { value: "road", label: "임도가 쓰는 것", rows: 120 }],
|
||||
default: "road",
|
||||
};
|
||||
const filtered = [filterValue(roadFilter, {}), filterValue(roadFilter, { use: "all" })];
|
||||
|
||||
// 열 제목 누르기 — 없음 → 오름 → 내림 → 없음(원본 차례) · 다른 열을 누르면 그 열 오름부터
|
||||
const sorts = [
|
||||
@@ -168,6 +179,9 @@ writeFileSync(process.argv[2], JSON.stringify({
|
||||
cellText("보통인부", null),
|
||||
// 소수 끝 떠돌이 자리(부동소수) — 기계 손료계수 0.0001811 이 0.00018109999999999998 로 보이던 것
|
||||
cellText(0.00018109999999999998, null),
|
||||
// 공종 축 목록 칸 — 괄호·따옴표 글자로 뜨던 것(2026-09-17 브레인 ④)
|
||||
cellText(["연암", "보통암", "경암"], null),
|
||||
cellText([], null),
|
||||
],
|
||||
pages: [pageCount(0, 50), pageCount(6999, 50), pageCount(50, 50)],
|
||||
row: rowCells({ extra: "x", daily_wage_krw: 250000, occupation_name: null }, columns),
|
||||
@@ -205,6 +219,7 @@ writeFileSync(process.argv[2], JSON.stringify({
|
||||
bolds,
|
||||
sorts,
|
||||
grouped,
|
||||
filtered,
|
||||
}));
|
||||
"""
|
||||
|
||||
@@ -252,6 +267,8 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None:
|
||||
"예",
|
||||
"보통인부",
|
||||
"0.0001811",
|
||||
"연암, 보통암, 경암",
|
||||
"",
|
||||
]
|
||||
assert got["pages"] == [1, 140, 1]
|
||||
# columns 차례 · id 는 줄에 없어 「—」 · null 은 빈칸 · extra 는 안 그림
|
||||
@@ -323,9 +340,12 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None:
|
||||
None,
|
||||
{"key": "item_code", "desc": False},
|
||||
]
|
||||
# 서버 차례 그대로 갈래별로 묶임 · 새 kind·새 갈래도 저절로 섬(화면 목록이 없음)
|
||||
# 서버 차례 그대로 갈래별로 묶임 · 상자 이름은 서버 group_label · 새 kind·새 갈래도 저절로 섬
|
||||
assert got["grouped"] == [
|
||||
["base_price", ["labor", "oil"]],
|
||||
["pumsem_basis", ["coef", "machine_productivity"]],
|
||||
["기초단가", ["labor", "oil"]],
|
||||
["품셈 기준", ["coef", "machine_productivity"]],
|
||||
["공종 축", ["forest"]],
|
||||
["later_group", ["brand_new"]],
|
||||
]
|
||||
# 거르기 상자 — 첫 선택지가 아니라 서버 기본값(어긋나면 「전부」로 보이며 걸러진 목록이 뜸)
|
||||
assert got["filtered"] == ["road", "all"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Z01 마스터 데이터 — 읽기 API(갈래 → 파일 → 표 · 표 줄 쪽 나누기·검색) · 2026-09-15 브레인 새 판.
|
||||
|
||||
약속(브레인): 파일 목록·갈래·한글 이름·단위·보임은 **이름표 파일**(`resources/data_master_labels/`)에서만 ·
|
||||
표 = 화면이 표로 그릴 마디(이름표 91 이 정본) · ⭐ 트리 표 id 와 이름표 표 id 를 **양쪽으로** 세어 둘 다 0 ·
|
||||
표 = 화면이 표로 그릴 마디(이름표 92 이 정본) · ⭐ 트리 표 id 와 이름표 표 id 를 **양쪽으로** 세어 둘 다 0 ·
|
||||
열 이름 찾는 차례 「파일id/열key」 → 「열key」 → 영문 key · 줄 한 줄 = {열key: 값} · file·table 은 트리의 id.
|
||||
"""
|
||||
|
||||
@@ -50,12 +50,13 @@ def test_갈래는_이름표_차례_파일_34_강우_캐시는_없음(client: Te
|
||||
assert [g["key"] for g in groups] == [k for k in LABELS["kinds"]]
|
||||
counts = {g["key"]: len(g["files"]) for g in groups}
|
||||
# 2026-09-16 사용자 판정 — 한 현장 관찰값·교본 설명 섞임 둘은 참고 사례로 옮겼다.
|
||||
# 2026-09-17 공종 축 산출 일곱을 올림 — logic 15 → 19 · byproduct 4 → 6 · fingerprint 3 → 4
|
||||
assert counts == {
|
||||
"logic": 15,
|
||||
"logic": 19,
|
||||
"base_value": 9,
|
||||
"byproduct": 4,
|
||||
"byproduct": 6,
|
||||
"reference": 2,
|
||||
"fingerprint": 3,
|
||||
"fingerprint": 4,
|
||||
"seed": 1,
|
||||
}
|
||||
assert {g["key"]: g["label"] for g in groups}["base_value"] == "기초값"
|
||||
@@ -83,7 +84,9 @@ def test_트리_표_id_와_이름표_표_id_는_양쪽으로_같음(client: Test
|
||||
label_only += [f"{entry['file_id']}::{i}" for i in sorted(label_ids - tree_ids)]
|
||||
assert not_labelled == [], not_labelled
|
||||
assert label_only == [], label_only
|
||||
assert sum(len(t) for t in files.values()) == LABELS["counts"]["tables"] == 91
|
||||
# 2026-09-17 92 → 101 — 공종 축 산출 일곱 + 갈래 열쇠 장부 표.
|
||||
# 2026-09-17 101 → 102 — 건설 열쇠 장부에도 갈래 표(건설 갈래 펴기 ④).
|
||||
assert sum(len(t) for t in files.values()) == LABELS["counts"]["tables"] == 102
|
||||
for entry in LABELS["files"]: # 이름이 실제로 붙음 · 줄 수도 이름표 셈과 같음
|
||||
named = {t["key"]: t for t in entry["tables"]}
|
||||
for t in files[entry["file_id"]]:
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Z01 공종 축 API 틀 — 산림·건설 두 kind 를 기초단가와 같은 길로(2026-09-17 브레인 · PLAN_공종축 8-4).
|
||||
|
||||
못박는 것: 평평한 잎 목록 · 전체 경로 이름(겹침 없음) · 계산 규칙(choose_one 91 · sum_steps 3) ·
|
||||
불변 열쇠가 `@id` · 건설도 같은 길(자료가 있어야 상자가 섬) · 고치기는 막힘.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from Z01_MasterData import Z01_MasterData_Overrides as overrides
|
||||
from Z01_MasterData import Z01_MasterData_Router as router_module
|
||||
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
from common_util.common_util_auth import verify_session
|
||||
|
||||
monkeypatch.setattr(overrides, "OVERRIDE_DIR", tmp_path / "data_master_override")
|
||||
app = FastAPI()
|
||||
app.include_router(router_module.router)
|
||||
app.dependency_overrides[verify_session] = lambda: {"user_id": 7, "role": "SYSTEM_ADMIN"}
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _get(client: TestClient, kind: str, **params) -> dict:
|
||||
res = client.get(f"/api/master-data/base-prices/{kind}", params=params)
|
||||
assert res.status_code == 200, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> None:
|
||||
listed = client.get("/api/master-data/base-prices").json()["kinds"]
|
||||
forest = next(i for i in listed if i["kind"] == "work_item_forest")
|
||||
assert (
|
||||
forest["group"] == "work_item" and forest["rows"] == 620
|
||||
) # 갈래마다 한 줄 · 헛단위 7줄 뺌(2026-09-17)
|
||||
# 상자 이름은 이름표 것 — 영문 키(work_item)로 뜨던 자리(2026-09-17 브레인 ①)
|
||||
assert {i["group"]: i["group_label"] for i in listed} == {
|
||||
"base_price": "기초단가",
|
||||
"pumsem_basis": "품셈 기준",
|
||||
"work_item": "공종 축",
|
||||
}
|
||||
|
||||
table = _get(client, "work_item_forest", size=500, axis_role="") # 「전부」 — 총칙까지
|
||||
assert table["total"] == 620 and table["stats"] == {"work_items": 360, "units": 620}
|
||||
assert table["editable"] == [] and set(table["locked"]) == set(table["sortable"])
|
||||
rows = table["rows"] + _get(client, "work_item_forest", size=500, page=2, axis_role="")["rows"]
|
||||
assert len({r["@id"] for r in rows}) == 620
|
||||
assert (
|
||||
len({r["@key"] for r in rows}) == len({r["name"] for r in rows}) == 360
|
||||
) # 잎 이름 겹침 — 경로로 풀림 · 목차 오기 바로잡아 358 → 360(12-17-2 · 12-24-1)
|
||||
by_code = {r["work_item_code"]: r for r in rows}
|
||||
assert by_code["FP-09-03-02"]["name"] == "토공 › 토사깍기 › 기계"
|
||||
|
||||
modes = [r["mode"] for r in table["rules"]]
|
||||
assert modes.count("choose_one") == 91 and modes.count("sum_steps") == 3
|
||||
blast = next(r for r in table["rules"] if r["work_item_code"] == "FP-09-05")
|
||||
assert [s["weight"] for s in blast["steps"]] == ["0.1", "0.9", "1"]
|
||||
# 계산 규칙 칸은 이름표 이름 — 「sum_steps」 영문으로는 더해야 하는 줄로 안 읽힘(브레인 ②)
|
||||
assert by_code["FP-09-05-02"]["rule"] == "아래를 단계로 다 더함"
|
||||
assert by_code["FP-09-03-02"]["rule"] == "아래 하나를 고름"
|
||||
assert by_code["FP-09-05-02"]["step_weight"] == "0.9"
|
||||
# 2026-09-17 FW- 가 옴(데스크탑 서브) — 알림이 사라지는 것이 곧 열쇠가 섰다는 증거.
|
||||
assert all(r["@id"].startswith("FW-") for r in rows) # 불변 열쇠(데스크탑 서브 7dd2c515)
|
||||
assert not any("불변 열쇠" in line for line in table["notice"])
|
||||
|
||||
found = _get(client, "work_item_forest", q="발파암 ›")
|
||||
assert found["total"] == 5 # 절취 · 깍기(연암·보통암·경암) · 집토
|
||||
|
||||
|
||||
def test_건설_공종_축도_같은_길(client: TestClient) -> None:
|
||||
"""건설(2026-09-17 뽑음) — 부문이 뿌리라 경로가 부문부터 · 열쇠 CW- · 계산 규칙은 전부 choose_one.
|
||||
갈래도 산림과 같은 표 읽기로 폄(④ · 공종 997 → 줄 1,293 · 갈래 열쇠 `CW-00063#01`)."""
|
||||
table = _get(client, "work_item_const", size=500, axis_role="")
|
||||
assert table["stats"] == {"work_items": 997, "units": 1293} and table["total"] == 1293
|
||||
assert sum(1 for r in table["rows"] if "#" in r["@id"]) > 0
|
||||
rows = table["rows"]
|
||||
assert all(r["@id"].startswith("CW-") for r in rows)
|
||||
assert all(r["name"].split(" › ")[0].endswith("부문") for r in rows)
|
||||
assert {r["mode"] for r in table["rules"]} == {"choose_one"}
|
||||
assert not any("불변 열쇠" in line for line in table["notice"])
|
||||
found = _get(client, "work_item_const", q="굴착(인력/토사)")
|
||||
assert [r["name"] for r in found["rows"]] == ["공통부문 › 토공사 › 굴착 › 굴착(인력/토사)"]
|
||||
|
||||
|
||||
def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림(
|
||||
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
folder = tmp_path / "data_work_item_master"
|
||||
folder.mkdir()
|
||||
fake = {
|
||||
"dataset_version": {"dataset_id": "pum_const"},
|
||||
"effective_date": "2026-01-01",
|
||||
"work_items": [
|
||||
{"work_item_code": "CP-01", "work_item_key": "CW-00001", "number": "1",
|
||||
"name": "토공", "parent_code": None, "tables": [], "parent_mode": "sum_steps",
|
||||
"steps": [{"code": "CP-01-01", "weight": "0.5"}], "steps_basis": "시험", "variant_keys": []},
|
||||
{"work_item_code": "CP-01-01", "work_item_key": "CW-00002", "number": "1-1",
|
||||
"name": "인력", "parent_code": "CP-01", "tables": [{"pum_table_id": "C0001"}],
|
||||
"variant_keys": ["갑", "을"],
|
||||
"variants": [{"variant_key": "01", "name": "갑", "name_clean": "갑"},
|
||||
{"variant_key": "02", "name": "을", "name_clean": "을"}]},
|
||||
],
|
||||
} # fmt: skip
|
||||
(folder / "const_work_item_master_2026-01-01.json").write_text(
|
||||
json.dumps(fake, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(work_items, "FOLDER", folder)
|
||||
|
||||
table = _get(client, "work_item_const")
|
||||
# 갈래마다 한 줄 · 열쇠는 **글자가 아니라 번호**(2026-09-17 브레인 — 장부가 준다)
|
||||
assert [r["@id"] for r in table["rows"]] == ["CW-00002#01", "CW-00002#02"]
|
||||
assert {r["@key"] for r in table["rows"]} == {"CW-00002"}
|
||||
assert table["rows"][0]["work_item_code"] == "CP-01-01" # 목차 코드는 칸으로 남음
|
||||
assert table["rows"][0]["name"] == "토공 › 인력"
|
||||
assert table["stats"] == {"work_items": 1, "units": 2}
|
||||
assert table["rules"][0]["steps"] == [{"@id": "CW-00002", "weight": "0.5"}]
|
||||
assert _get(client, "work_item_forest")["total"] == 0 # 산림 파일은 이 폴더에 없음
|
||||
|
||||
res = client.put(
|
||||
"/api/master-data/base-prices/work_item_const/CW-00002%2301",
|
||||
json={"values": {"name": "바꿈"}},
|
||||
)
|
||||
assert res.status_code == 400 and "공종 축 뼈대" in res.json()["detail"]
|
||||
|
||||
|
||||
def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestClient) -> None:
|
||||
"""화면(서브 b890fba0)이 `filters` 를 받으면 상자를 세우고 고른 값만 보냄 — 판정은 서버 한 곳.
|
||||
건설 부문 다섯 · 줄 구실(이름은 axis_policy.json) · 연결(연결고리 표)."""
|
||||
const = _get(client, "work_item_const", size=1, axis_role="")
|
||||
by_key = {f["key"]: f for f in const["filters"]}
|
||||
assert set(by_key) == {"link", "division", "axis_role"}
|
||||
division = by_key["division"]
|
||||
assert division["default"] == ""
|
||||
assert division["options"][0] == {"value": "", "label": "전부", "rows": const["total"]}
|
||||
assert [o["value"] for o in division["options"][1:]] == [
|
||||
"공통부문",
|
||||
"토목부문",
|
||||
"건축부문",
|
||||
"기계설비부문",
|
||||
"유지관리부문",
|
||||
]
|
||||
assert sum(o["rows"] for o in division["options"][1:]) == const["total"]
|
||||
# 2026-09-17 목록에서는 「총칙」이 아니라 **공종이 아님**을 드러내는 이름(잣대 `list_label`).
|
||||
assert {o["value"]: o["label"] for o in by_key["axis_role"]["options"]}[
|
||||
"general_provision"
|
||||
] == "공종 아님 · 규정·기준"
|
||||
assert by_key["axis_role"]["default"] == "work_item" # 감춘 것이지 지운 것이 아님
|
||||
|
||||
civil = _get(client, "work_item_const", size=500, division="토목부문", axis_role="")
|
||||
assert civil["total"] == next(
|
||||
o["rows"] for o in division["options"] if o["value"] == "토목부문"
|
||||
)
|
||||
assert all(r["name"].startswith("토목부문 › ") for r in civil["rows"])
|
||||
rules = _get(
|
||||
client, "work_item_const", size=500, division="공통부문", axis_role="general_provision"
|
||||
)
|
||||
assert rules["total"] > 0
|
||||
# 2026-09-17 건설 총칙 뿌리가 둘 — 적용기준(37) + 건설기계(75 · 산림 FP-02 와 짝)
|
||||
assert all(
|
||||
r["name"].startswith(("공통부문 › 적용기준", "공통부문 › 건설기계")) for r in rules["rows"]
|
||||
)
|
||||
role_names = {o["value"]: o["label"] for o in by_key["axis_role"]["options"]}
|
||||
# 표시 칸 — 이름은 잣대(`list_label` · 「공종 아님 · 규정·기준」) 한 곳
|
||||
assert {r["axis_role"] for r in rules["rows"]} == {role_names["general_provision"]}
|
||||
|
||||
assert [f["key"] for f in _get(client, "work_item_forest", size=1)["filters"]] == [
|
||||
"link",
|
||||
"axis_role",
|
||||
]
|
||||
assert _get(client, "labor", size=1, division="토목부문")["total"] == 261 # 다른 표는 안 거름
|
||||
|
||||
|
||||
def test_연결고리_표가_이은_줄에_연결됨_감추지_않고_표시만(client: TestClient) -> None:
|
||||
"""브레인 2026-09-17 — 「임도가 쓰는 것」 = 연결고리 표(work_item_link)가 **확정**으로 이은 줄.
|
||||
기본은 다 보임 · 표시 「연결됨」 · 「연결된 것만」 상자 하나 · 판정은 그 표(코드에 목록 없음)."""
|
||||
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
|
||||
|
||||
link_doc = json.loads(
|
||||
sorted(work_items.LINK_FOLDER.glob("work_item_link_*.json"))[-1].read_text(encoding="utf-8")
|
||||
)
|
||||
confirmed = {
|
||||
key
|
||||
for link in link_doc["links"]
|
||||
if link["status"] == "확정"
|
||||
for key in (link["forest_key"], link["const_key"])
|
||||
if key
|
||||
}
|
||||
unconfirmed = {
|
||||
key
|
||||
for link in link_doc["links"]
|
||||
if link["status"] != "확정"
|
||||
for key in (link["forest_key"], link["const_key"])
|
||||
if key
|
||||
} - confirmed
|
||||
for kind, prefix in (("work_item_const", "CW-"), ("work_item_forest", "FW-")):
|
||||
every = _get(client, kind, size=500, axis_role="")
|
||||
rows = [
|
||||
row
|
||||
for page in range(1, every["total"] // 500 + 2)
|
||||
for row in _get(client, kind, size=500, page=page, axis_role="")["rows"]
|
||||
]
|
||||
assert len(rows) == every["total"] # 연결 거르기 기본은 「전부」 — 다 보임
|
||||
marked = {r["@key"] for r in rows if r["link"] == "연결됨"} # 갈래 줄은 공종 열쇠로
|
||||
listed = {r["@key"] for r in rows} # 표가 안 붙은 묶는 마디(CW-01329 따위)는 목록 밖
|
||||
assert marked == {k for k in confirmed if k.startswith(prefix) and k in listed}
|
||||
assert marked # 연결이 늘면 저절로 늘어남 — 지금도 한 줄 이상
|
||||
assert not marked & unconfirmed
|
||||
only = _get(client, kind, size=500, link="연결됨", axis_role="")
|
||||
assert {r["@key"] for r in only["rows"]} == marked
|
||||
box = next(f for f in every["filters"] if f["key"] == "link")
|
||||
assert box["default"] == "" and box["options"][1]["rows"] == only["total"]
|
||||
|
||||
|
||||
def test_갈래마다_한_줄이라_편_하나를_집는다(client: TestClient) -> None:
|
||||
"""2026-09-17 브레인 ③ — 일위대가는 갈래마다 다름. 교목굴취(나무높이) 한 줄이면 어느 높이 값인지 못 고름."""
|
||||
rows = _get(client, "work_item_forest", size=50, q="교목굴취(나무높이)")["rows"]
|
||||
assert [r["variant"] for r in rows] == [
|
||||
"1.0 이하", "1.1∼1.5", "1.6∼2.0", "2.1∼2.5", "2.6∼3.0",
|
||||
"3.1∼3.5", "3.6∼4.0", "4.1∼4.5", "4.6∼5.0",
|
||||
] # fmt: skip
|
||||
# 열쇠에 갈래 **글자**를 담지 않는다 — 원문 물결표·자간이 다듬어지면 열쇠가 갈린다(브레인).
|
||||
assert rows[1]["@id"] == "FW-00105#02" and rows[1]["@key"] == "FW-00105"
|
||||
assert rows[1]["variant"] == "1.1∼1.5" # 글자는 보일 이름 칸으로
|
||||
# 갈래 없는 공종은 한 줄 · 열쇠에 # 없음
|
||||
plain = _get(client, "work_item_forest", q="교목굴취(근원직경)")["rows"]
|
||||
assert [(r["@id"], r["variant"]) for r in plain] == [("FW-00106", None)]
|
||||
# 갈래 이름으로도 찾힘
|
||||
assert _get(client, "work_item_forest", q="2.1∼2.5")["total"] >= 1
|
||||
|
||||
|
||||
def test_총칙_줄은_기본_감춤_전부를_고르면_보임(client: TestClient) -> None:
|
||||
"""2026-09-17 브레인 — 값을 안 보내면 상자 기본값(공종)으로 거름 · 화면은 그 기본값을 상자에 보임."""
|
||||
for kind in ("work_item_forest", "work_item_const"):
|
||||
shown = _get(client, kind, size=500)
|
||||
role = next(f for f in shown["filters"] if f["key"] == "axis_role")
|
||||
assert role["default"] == "work_item"
|
||||
counts = {o["value"]: o["rows"] for o in role["options"]}
|
||||
assert counts["general_provision"] > 0
|
||||
# 기본 = 공종만 · 찾기 결과로 기본이 흔들리지 않음(총칙만 걸리는 낱말도 기본은 감춤)
|
||||
assert shown["total"] == counts["work_item"]
|
||||
assert (
|
||||
_get(client, kind, size=500, q="적용기준")["total"]
|
||||
< _get(client, kind, size=500, q="적용기준", axis_role="")["total"]
|
||||
)
|
||||
# 「전부」(빈 값을 보냄) → 총칙까지
|
||||
assert _get(client, kind, size=1, axis_role="")["total"] == counts[""]
|
||||
assert (
|
||||
_get(client, kind, size=1, axis_role="general_provision")["total"]
|
||||
== (counts["general_provision"])
|
||||
)
|
||||
|
||||
|
||||
def test_찾기는_물결표_가운뎃점_띄어쓰기를_같게_봄(client: TestClient) -> None:
|
||||
"""원문이 물결표를 둘(∼·~)로 쓰고 자간 공백이 들쭉날쭉 — 사람이 친 「~」·붙여 쓴 이름으로도 찾힘.
|
||||
⚠ 찾기만 — 줄 값·열쇠는 원문 그대로(서브 훑기 · 브레인 2026-09-17)."""
|
||||
wavy = _get(client, "work_item_forest", size=50, q="2.1∼2.5")["total"]
|
||||
assert wavy >= 1
|
||||
assert _get(client, "work_item_forest", size=50, q="2.1~2.5")["total"] == wavy
|
||||
assert _get(client, "work_item_forest", size=50, q="2.1~2.5")["total"] == wavy
|
||||
rows = _get(client, "work_item_forest", size=50, q="2.1~2.5")["rows"]
|
||||
assert all("~" not in r["@id"] for r in rows) # 열쇠는 손질 안 함
|
||||
spaced = _get(client, "work_item_forest", size=50, q="토사 깍기", axis_role="")["total"]
|
||||
assert (
|
||||
spaced == _get(client, "work_item_forest", size=50, q="토사깍기", axis_role="")["total"] > 0
|
||||
)
|
||||
|
||||
|
||||
def test_이름_다듬기는_보일_이름만_열쇠는_그대로(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""⭐ 갈래 불변 열쇠를 세운 까닭(브레인 2026-09-17) — 자간 공백(「측 량」·「평 규 준 틀」)을 다듬어도
|
||||
줄 열쇠(`FW-…#01`)가 한 글자도 안 흔들림. 다듬기를 끈 목록과 켠 목록의 열쇠를 맞댐."""
|
||||
from B08_Quantity import B08_Quantity_Build_WorkItemMaster_Keys as keys
|
||||
from Z01_MasterData import Z01_MasterData_WorkItems as work_items
|
||||
|
||||
for kind in work_items.KINDS:
|
||||
tidy = work_items.base_rows(kind)
|
||||
with monkeypatch.context() as patch:
|
||||
patch.setattr(keys, "tidy_spacing", lambda text: " ".join(str(text or "").split()))
|
||||
raw = work_items.base_rows(kind)
|
||||
assert [r["@id"] for r in tidy] == [r["@id"] for r in raw]
|
||||
if kind == "work_item_const":
|
||||
assert (
|
||||
sum(a["name"] != b["name"] for a, b in zip(tidy, raw)) > 0
|
||||
) # 실제로 다듬어진 줄이 있음
|
||||
shown = _get(client, "work_item_const", size=50, q="기준점 측량", axis_role="")["rows"]
|
||||
assert shown and all("측 량" not in r["name"] and "› 측량 ›" in r["name"] for r in shown)
|
||||
|
||||
|
||||
def test_품셈_번호는_마디마다_수로_세움(client: TestClient) -> None:
|
||||
"""글자로 세우면 「10-1」이 「2-1」 앞에 섬(서브 훑기) — 마디마다 수로."""
|
||||
rows = _get(client, "work_item_forest", size=500, sort="number", axis_role="")["rows"]
|
||||
numbers = [tuple(int(x) for x in r["number"].split("-")) for r in rows if "-" in r["number"]]
|
||||
assert len(numbers) > 300 and numbers == sorted(numbers)
|
||||
|
||||
|
||||
def test_건설_늘_빈_칸은_감춤(client: TestClient) -> None:
|
||||
"""건설은 합산형 선언이 없어 「단계 무게」가 늘 빈칸 — 감춤(산림은 보임)."""
|
||||
hidden = lambda kind: {c["key"]: c["hidden"] for c in _get(client, kind, size=1)["columns"]} # noqa: E731
|
||||
assert hidden("work_item_const")["step_weight"] is True
|
||||
assert hidden("work_item_forest")["step_weight"] is False
|
||||
Reference in New Issue
Block a user