Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1
랩탑 메인이 건설 공종 축·목차 오기 고침·파일 쪼개기를 냈음. 충돌 셋 해소: - **열쇠 벌**(`_Keys.py`) — 랩탑의 갈래별 매개(`key_prefix`·`general_roots`)를 취하고, 내 잣대 읽기(`axis_policy.json`)를 되살려 **기본값이 자료에서 옴** ⇒ 랩탑이 코드로 되돌려 둔 `GENERAL_PROVISION_ROOTS` 는 잣대를 못 읽는 자리용 기본값으로만 남김 - **산림 마스터·지문** — 랩탑 것(코덱스 원문 대조로 표 넷 제자리 · 잎 358 → 360) - **이름표** — 랩탑이 건설 kind 를 정식으로 냈으므로 내 「기다리는 자리」(`pending_merged_tables`) 버림 · 공용 `columns` 여덟과 값 낱말 여섯은 살림 · `toc_duplicate_rows` 이름표는 뺌 (오기 고침으로 빈 목록이 되어 「묵은 이름표」가 됨 — 다시 생기면 그때 붙일 것) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsGw1Dz9HmhuAxGisxmDPF
This commit is contained in:
@@ -33,7 +33,26 @@ 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"
|
||||
@@ -44,105 +63,11 @@ 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]] = []
|
||||
@@ -161,25 +86,66 @@ 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
|
||||
|
||||
|
||||
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):
|
||||
@@ -198,393 +164,6 @@ def section_number(section: str) -> str | None:
|
||||
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() -> tuple[
|
||||
dict[str, Any],
|
||||
list[dict[str, Any]],
|
||||
@@ -606,16 +185,12 @@ def build() -> tuple[
|
||||
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:
|
||||
@@ -632,88 +207,10 @@ def build() -> tuple[
|
||||
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
|
||||
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
|
||||
@@ -728,19 +225,58 @@ def build() -> tuple[
|
||||
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,
|
||||
decorate,
|
||||
general_provision_roots,
|
||||
load_registry,
|
||||
toc_edition,
|
||||
)
|
||||
|
||||
edition = toc_edition(data.get("sources"), data["effective_date"])
|
||||
registry = load_registry(KEY_REGISTRY)
|
||||
registry = load_registry(registry_path, key_prefix)
|
||||
newly_issued, toc_duplicates = assign_keys(nodes, registry, edition=edition)
|
||||
decorate(nodes, edition=edition)
|
||||
# 총칙 뿌리는 `axis_policy.json` — 코드에 박으면 관리자가 못 고친다(데스크탑 서브 2026-09-17).
|
||||
roots = general_provision_roots("forest") if general_roots is None else general_roots
|
||||
decorate(nodes, edition=edition, general_roots=roots)
|
||||
role_counts: dict[str, int] = {}
|
||||
for node in nodes:
|
||||
role_counts[node["axis_role"]] = role_counts.get(node["axis_role"], 0) + 1
|
||||
@@ -748,13 +284,13 @@ def build() -> tuple[
|
||||
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"],
|
||||
@@ -771,44 +307,60 @@ def build() -> tuple[
|
||||
# **그 판의 목차 번호**라 개정 때 밀린다 — 열쇠로 쓰지 말 것.
|
||||
"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),
|
||||
"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, (registry, newly_issued) = 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(KEY_REGISTRY, 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(
|
||||
@@ -859,11 +411,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']})"
|
||||
@@ -880,7 +433,7 @@ def main() -> None:
|
||||
roles.get("empty", 0),
|
||||
)
|
||||
)
|
||||
print(f"열쇠 새로 발급 {len(newly_issued)} (장부 {KEY_REGISTRY.name})")
|
||||
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,299 @@
|
||||
"""건설공사 표준품셈 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_role` 은 공종·묶음·빈 잎).
|
||||
⚠ 갈래 키 안 붙임 — B09 표 읽기가 산림 표로만 검증됨(빈칸 = 못 가름).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Keys import general_provision_roots
|
||||
|
||||
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 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)
|
||||
for node in nodes:
|
||||
node["variant_keys"] = [] # 못 가름 — B09 표 읽기가 산림 표로만 검증됨
|
||||
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,
|
||||
# 총칙 잣대 도착(데스크탑 서브 2026-09-17) — `axis_policy.json` 의 건설 뿌리.
|
||||
# ⚠ 뿌리에 부문 자리가 들어 있다(`CP-01-01`) — 부문마다 장 번호가 1부터 다시 시작해서다.
|
||||
general_roots=general_provision_roots("const"),
|
||||
policy={
|
||||
"divisions": list(DIVISIONS),
|
||||
"toc_source": BOOK_NAME + " 머리 「목 차」",
|
||||
"section_from_body": True,
|
||||
"variant_keys_attached": False,
|
||||
"general_provision_marked": False,
|
||||
},
|
||||
)
|
||||
@@ -23,7 +23,9 @@ from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
KEY_PREFIX = "FW" # Forest Work item — 산림 공종 열쇠
|
||||
KEY_PREFIX = (
|
||||
"FW" # Forest Work item — 산림 공종 열쇠 · 건설은 장부를 따로 두고 `CW`(장부 `key_prefix`)
|
||||
)
|
||||
KEY_DIGITS = 5
|
||||
PATH_SEP = " › "
|
||||
|
||||
@@ -46,10 +48,18 @@ def load_policy(axis: str, path: Path = AXIS_POLICY) -> dict[str, Any]:
|
||||
|
||||
|
||||
def general_provision_roots(axis: str = "forest", path: Path = AXIS_POLICY) -> tuple[str, ...]:
|
||||
"""총칙 뿌리 — 값표는 붙어 있으나 공종이 아닌 장. ⚠ 지우지 않고 구실로만 가른다."""
|
||||
"""총칙 뿌리 — 값표는 붙어 있으나 공종이 아닌 장. ⚠ 지우지 않고 구실로만 가른다.
|
||||
|
||||
⚠ 뿌리는 **목차 코드**다(산림 `FP-01` · 건설 `CP-01-01`). 건설은 부문마다 장 번호를
|
||||
각자 1부터 세므로 부문 자리까지 있어야 갈린다.
|
||||
"""
|
||||
return tuple(load_policy(axis, path).get("general_provision_roots") or ())
|
||||
|
||||
|
||||
#: 잣대를 못 읽는 자리(시험 등)에서 쓰는 산림 기본값. **정본은 `axis_policy.json`.**
|
||||
GENERAL_PROVISION_ROOTS = ("FP-01", "FP-02")
|
||||
|
||||
|
||||
#: 원문 경로에 박힌 고시 번호 — 「(산림청고시 제2025-82호)」.
|
||||
_EDITION_RE = re.compile(r"([가-힣]*고시\s*제\s*\d{4}-\d+\s*호)")
|
||||
|
||||
@@ -65,7 +75,7 @@ def toc_edition(sources: list[dict[str, Any]] | None, fallback: str) -> str:
|
||||
return fallback
|
||||
|
||||
|
||||
def empty_registry() -> dict[str, Any]:
|
||||
def empty_registry(prefix: str = KEY_PREFIX) -> dict[str, Any]:
|
||||
"""빈 장부. 첫 판을 지을 때 이 꼴로 시작한다."""
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
@@ -82,7 +92,7 @@ def empty_registry() -> dict[str, Any]:
|
||||
"물려주지 않는다 — 잇는 것은 이 파일에 손으로 적을 때만."
|
||||
),
|
||||
},
|
||||
"key_prefix": KEY_PREFIX,
|
||||
"key_prefix": prefix,
|
||||
"key_digits": KEY_DIGITS,
|
||||
"next_serial": 1,
|
||||
"editions": {},
|
||||
@@ -90,10 +100,10 @@ def empty_registry() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def load_registry(path: Path) -> dict[str, Any]:
|
||||
def load_registry(path: Path, prefix: str = KEY_PREFIX) -> dict[str, Any]:
|
||||
"""장부를 읽는다. 없으면 빈 장부."""
|
||||
if not path.exists():
|
||||
return empty_registry()
|
||||
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:
|
||||
@@ -106,8 +116,8 @@ def save_registry(path: Path, registry: dict[str, Any]) -> None:
|
||||
path.write_text(json.dumps(registry, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def format_key(serial: int) -> str:
|
||||
return f"{KEY_PREFIX}-{serial:0{KEY_DIGITS}d}"
|
||||
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:
|
||||
@@ -146,7 +156,7 @@ def assign_keys(
|
||||
)
|
||||
key = book.get(slot)
|
||||
if key is None:
|
||||
key = format_key(registry["next_serial"])
|
||||
key = format_key(registry["next_serial"], registry.get("key_prefix", KEY_PREFIX))
|
||||
registry["next_serial"] += 1
|
||||
book[slot] = key
|
||||
registry["keys"][key] = {
|
||||
@@ -193,21 +203,25 @@ def path_names(nodes: list[dict[str, Any]]) -> list[str]:
|
||||
|
||||
|
||||
def axis_role(
|
||||
node: dict[str, Any], *, has_children: bool, roots: tuple[str, ...] | None = None
|
||||
node: dict[str, Any],
|
||||
*,
|
||||
has_children: bool,
|
||||
general_roots: tuple[str, ...] | None = None,
|
||||
) -> str:
|
||||
"""이 줄이 공종인가 문서 구역인가. 차례가 뜻을 가진다(총칙 먼저).
|
||||
|
||||
`work_item` 품셈 표가 붙은 줄 — 값이 있는 자리
|
||||
`group` 표는 없고 아래를 묶는 마디 — `parent_mode` 가 계산 규칙을 지닌다(지우면 안 된다)
|
||||
`general_provision` 총칙 — 문서 구역이나 **값표가 붙어 있어 B09 가 읽는다**
|
||||
`general_provision` 총칙(1·2장) — 문서 구역이나 **값표가 붙어 있어 B09 가 읽는다**
|
||||
`empty` 표도 아래도 없는 잎 — 원문에 표가 안 붙은 자리
|
||||
|
||||
⚠ **어느 구실이든 지우지 않는다.** 가름은 화면·조합이 무엇을 볼지 고르는 것뿐이다.
|
||||
`general_roots` 를 안 주면 잣대 자료의 산림 뿌리를 쓴다.
|
||||
"""
|
||||
if roots is None:
|
||||
roots = general_provision_roots()
|
||||
if general_roots is None:
|
||||
general_roots = general_provision_roots()
|
||||
code = node["work_item_code"]
|
||||
if any(code == root or code.startswith(root + "-") for root in roots):
|
||||
if any(code == root or code.startswith(root + "-") for root in general_roots):
|
||||
return "general_provision"
|
||||
if node.get("tables"):
|
||||
return "work_item"
|
||||
@@ -215,14 +229,17 @@ def axis_role(
|
||||
|
||||
|
||||
def decorate(
|
||||
nodes: list[dict[str, Any]], *, edition: str, roots: tuple[str, ...] | None = None
|
||||
nodes: list[dict[str, Any]],
|
||||
*,
|
||||
edition: str,
|
||||
general_roots: tuple[str, ...] | None = None,
|
||||
) -> None:
|
||||
"""각 줄에 목차 칸·경로 이름·구실을 얹는다. 열쇠는 `assign_keys` 가 이미 박았다.
|
||||
|
||||
기존 칸은 손대지 않는다 — 금액을 낳는 값은 그대로다.
|
||||
"""
|
||||
if roots is None:
|
||||
roots = general_provision_roots()
|
||||
if general_roots is None:
|
||||
general_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):
|
||||
@@ -231,4 +248,6 @@ def decorate(
|
||||
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)
|
||||
node["axis_role"] = axis_role(
|
||||
node, has_children=code in has_kid, general_roots=general_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
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Z01 공종 축 — 산림·건설 두 kind 를 기초단가와 **같은 계약**으로(2026-09-17 브레인 · PLAN_공종축 8-4).
|
||||
|
||||
줄 = **잎**(아래 마디 없고 품셈 표가 붙은 마디) — 일위대가가 붙는 단위. 갈래(`variant_keys`)는 칸으로(편 수 = `stats.units`).
|
||||
이름 = 뿌리부터 전체 경로(「토공 › 토사깍기 › 기계」) — 잎 이름만으론 겹침(산림 358 중 35줄).
|
||||
이름 = 뿌리부터 전체 경로(「토공 › 토사깍기 › 기계」 · 건설은 부문부터) — 잎 이름만으론 겹침.
|
||||
계산 규칙 = 표 수준 `rules`(부모 마디 choose_one · sum_steps) + 줄 칸 `rule`·`step_weight`
|
||||
— 빠지면 발파암(절취 0.1 + 깍기 0.9 + 집토 1) 금액이 틀어짐.
|
||||
`@id` = 불변 열쇠(FW-·CW- · 데스크탑 서브가 붙이는 중) — 아직 없는 줄은 목차 코드(FP-)로 서고 알림에 드러냄.
|
||||
`@id` = 불변 열쇠(FW-·CW-) — 없는 줄은 목차 코드로 서고 알림에 드러냄.
|
||||
⚠ 읽기만 — 고칠 칸 없음(원문 값 풀기 8-7 뒤). 원본 `work_item_code`(FP-)는 B08·B09 가 씀 — 안 건드림.
|
||||
"""
|
||||
|
||||
@@ -19,7 +19,10 @@ 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_construction": "pum_const"}
|
||||
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"
|
||||
@@ -35,7 +38,7 @@ def _read(path: str, _mtime_ns: int) -> dict[str, Any]:
|
||||
def doc(kind: str) -> tuple[dict[str, Any], str] | None:
|
||||
"""그 품셈의 공종 축 끝 판(문서 · 파일 이름) — 없으면 None(건설은 아직 없음)."""
|
||||
found = []
|
||||
for path in sorted(FOLDER.glob("work_item_master_*.json")):
|
||||
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))
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
},
|
||||
"work_item": {
|
||||
"name_ko": "공종 축",
|
||||
"summary": "**무엇을 하나** — 품셈 목차에서 일위대가가 붙는 잎 공종을 평평하게 편 목록(산림·건설). 읽기만."
|
||||
"summary": "**무슨 일인가** — 품셈 목차가 세운 공종 나무를 잎으로 평평하게 편 목록(산림·건설). 줄의 열쇠는 `work_item_key`(FW·CW), 목차 번호는 칸이다. 읽기만."
|
||||
}
|
||||
},
|
||||
"merged_tables": [
|
||||
@@ -1366,7 +1366,7 @@
|
||||
"sources": [
|
||||
"work_item_master::work_items"
|
||||
],
|
||||
"rows_note": "잎 358 줄 · 갈래 펴면 618",
|
||||
"rows_note": "잎 360 줄 · 갈래 펴면 620",
|
||||
"columns": [
|
||||
{
|
||||
"key": "work_item_code",
|
||||
@@ -1421,6 +1421,69 @@
|
||||
"visible": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "work_item_const",
|
||||
"name_ko": "건설품셈 기준",
|
||||
"summary": "건설 품셈 공종 축의 잎 — 일위대가가 붙는 단위. 이름은 부문부터 전체 경로, 부모 마디 계산 규칙은 표 수준 `rules`.",
|
||||
"sources": [
|
||||
"work_item_master_const::work_items"
|
||||
],
|
||||
"rows_note": "잎 995 줄 · 갈래 키 아직 없음(편 수 = 잎 수)",
|
||||
"columns": [
|
||||
{
|
||||
"key": "work_item_code",
|
||||
"name_ko": "공종코드",
|
||||
"unit": "",
|
||||
"visible": true,
|
||||
"note": "건설 목차 코드(CP-부문-장-절-항). ⚠ 불변 열쇠 아님 — 개정 때 번호가 밀림. 열쇠는 `@id`(CW-)."
|
||||
},
|
||||
{
|
||||
"key": "number",
|
||||
"name_ko": "품셈 번호",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "name",
|
||||
"name_ko": "이름",
|
||||
"unit": "",
|
||||
"visible": true,
|
||||
"note": "부문부터 전체 경로(「공통부문 › 토공사 › 굴착 › 굴착(인력/토사)」) — 부문마다 장 번호가 1부터라 부문이 뿌리."
|
||||
},
|
||||
{
|
||||
"key": "variant_keys",
|
||||
"name_ko": "갈래 키",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "pum_tables",
|
||||
"name_ko": "딸린 품셈 표",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "rule",
|
||||
"name_ko": "계산 규칙",
|
||||
"unit": "",
|
||||
"visible": true,
|
||||
"note": "부모 마디 방식 — `choose_one`(아래 중 하나) · `sum_steps`(아래를 무게대로 더해야 한 단위)."
|
||||
},
|
||||
{
|
||||
"key": "step_weight",
|
||||
"name_ko": "단계 무게",
|
||||
"unit": "",
|
||||
"visible": true,
|
||||
"note": "`sum_steps` 일 때만 — 건설은 아직 합산형 선언 없음."
|
||||
},
|
||||
{
|
||||
"key": "effective_date",
|
||||
"name_ko": "적용 시작일",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"machine_cost_chain": {
|
||||
@@ -1759,11 +1822,20 @@
|
||||
{
|
||||
"table": "work_item_forest",
|
||||
"name_ko": "산림품셈 기준",
|
||||
"verdict": "있음(임시) — 지금은 목차 코드 · 불변 열쇠 오면 그 칸",
|
||||
"verdict": "있음",
|
||||
"key": [
|
||||
"work_item_code"
|
||||
"work_item_key"
|
||||
],
|
||||
"checked": "잎 358 줄 공종코드 겹침 0(전수 · `test_z01_work_items`). ⚠ 목차 번호라 품셈 개정으로 밀림 — 데스크탑 서브가 붙이는 불변 열쇠(FW-)가 오면 서버가 그 칸을 `@id` 로 씀(`Z01_MasterData_WorkItems.KEY_FIELD`)."
|
||||
"checked": "잎 360 줄 불변 열쇠(FW-) 겹침 0(전수 · `test_z01_work_items`). 목차 코드(FP-)는 칸으로만 — 개정 때 밀림."
|
||||
},
|
||||
{
|
||||
"table": "work_item_const",
|
||||
"name_ko": "건설품셈 기준",
|
||||
"verdict": "있음",
|
||||
"key": [
|
||||
"work_item_key"
|
||||
],
|
||||
"checked": "잎 995 줄 불변 열쇠(CW-) 겹침 0(전수 · `test_work_item_master_const`). 목차 코드(CP-)는 칸으로만 — 개정 때 밀림."
|
||||
}
|
||||
],
|
||||
"null_is_real": {
|
||||
@@ -1857,15 +1929,15 @@
|
||||
}
|
||||
},
|
||||
"counts": {
|
||||
"merged_tables": 16,
|
||||
"merged_columns": 164,
|
||||
"merged_tables": 17,
|
||||
"merged_columns": 172,
|
||||
"files": 34,
|
||||
"tables": 92,
|
||||
"columns": 472,
|
||||
"value_groups": 176,
|
||||
"columns": 471,
|
||||
"value_groups": 177,
|
||||
"unknown": 0,
|
||||
"value_groups_by_kind": {
|
||||
"doc": 110,
|
||||
"doc": 111,
|
||||
"value": 66
|
||||
}
|
||||
},
|
||||
@@ -2066,7 +2138,7 @@
|
||||
"name_ko": "공종에 못 붙인 표",
|
||||
"summary": "목차 어디에도 못 붙인 품셈 표.",
|
||||
"shape": "list",
|
||||
"rows": 17,
|
||||
"rows": 15,
|
||||
"columns": [
|
||||
{
|
||||
"key": "pum_table_id",
|
||||
@@ -2194,24 +2266,12 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "toc_duplicate_rows",
|
||||
"name_ko": "목차 번호가 겹친 줄",
|
||||
"summary": "품셈 목차가 같은 번호를 두 번 적은 자리. 번호를 열쇠로 못 쓰는 실물이다.",
|
||||
"key": "toc_corrections",
|
||||
"name_ko": "목차 오기를 본문 번호로 바로잡은 줄",
|
||||
"summary": "품셈 목차가 같은 번호를 두 번 적은 자리를 본문 절 제목 번호로 고친 기록. 목차만 믿으면 표가 딴 줄에 붙는다.",
|
||||
"shape": "list",
|
||||
"rows": 2,
|
||||
"columns": [
|
||||
{
|
||||
"key": "toc_code",
|
||||
"name_ko": "목차 코드",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "toc_number",
|
||||
"name_ko": "목차 번호",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "name",
|
||||
"name_ko": "이름",
|
||||
@@ -2219,8 +2279,14 @@
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "slot",
|
||||
"name_ko": "장부 자리",
|
||||
"key": "toc_number",
|
||||
"name_ko": "목차에 적힌 번호",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "body_number",
|
||||
"name_ko": "본문 절 번호",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
}
|
||||
@@ -2245,6 +2311,12 @@
|
||||
"name_ko": "목차 판(고시)",
|
||||
"summary": "목차 번호가 묶인 고시 판. 값이 아니라 **판을 가리키는 표**다.",
|
||||
"kind": "doc"
|
||||
},
|
||||
{
|
||||
"key": "toc_duplicate_rows",
|
||||
"name_ko": "목차 번호가 겹친 줄",
|
||||
"summary": "목차가 같은 번호를 두 번 적었는데 본문 번호로도 못 가른 자리. 비어 있어야 정상 — 줄이 생기면 표가 딴 줄에 붙는다.",
|
||||
"kind": "doc"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -7059,6 +7131,11 @@
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
"body_number": {
|
||||
"name_ko": "본문 절 번호",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
"bond_codes": {
|
||||
"name_ko": "쌓기 방식별 공종코드",
|
||||
"unit": "",
|
||||
@@ -8676,72 +8753,104 @@
|
||||
}
|
||||
},
|
||||
"unknown": [],
|
||||
"pending_merged_tables": {
|
||||
"note": "**서버가 아직 안 내는 kind** 의 이름표를 미리 지어 둔 자리. `merged_tables` 는 서버 kind 와 한 짝이어야 해서(묵은 열 시험) 여기 둔다. 서버가 kind 를 내는 날 **그대로 `merged_tables` 로 옮기고** `test_master_labels_cover.py` 의 `MERGED_KEYS` 에 한 줄 더할 것.",
|
||||
"items": [
|
||||
"work_item_axis": {
|
||||
"note": "공종 축(불변 열쇠 FW·CW) 화면 이름표. 기초데이터 열넷 셈(`merged_tables`)에 섞이지 않게 여기 따로 둔다. Z01 이 이 구획을 읽어 상자 이름·열 이름을 뜨면 된다.",
|
||||
"group": "work_item",
|
||||
"kinds": [
|
||||
{
|
||||
"key": "work_item_forest",
|
||||
"name_ko": "산림 공종",
|
||||
"summary": "산림사업 표준품셈 목차가 세운 공종 나무.",
|
||||
"source": "resources/data_work_item_master/work_item_master_2026-01-01.json",
|
||||
"key_prefix": "FW"
|
||||
},
|
||||
{
|
||||
"key": "work_item_const",
|
||||
"name_ko": "건설품셈 기준",
|
||||
"summary": "건설공사 표준품셈 공종 축의 잎 — 일위대가가 붙는 단위. 산림과 같은 칸·같은 규칙이다. ⚠ 자리만 먼저 냄 — 뽑는 일은 랩탑 메인, 덜어낼 잣대는 `axis_policy.json`.",
|
||||
"sources": [
|
||||
"work_item_master_const::work_items"
|
||||
],
|
||||
"rows_note": "아직 안 뽑음 — 원문 표 2,192 · 목차 줄 2,000 안팎 예상",
|
||||
"columns": [
|
||||
{
|
||||
"key": "work_item_code",
|
||||
"name_ko": "공종코드",
|
||||
"unit": "",
|
||||
"visible": true,
|
||||
"note": "품셈 목차 코드(FP-). ⚠ 불변 열쇠 아님 — 개정 때 번호가 밀림. 열쇠는 `@id`(FW-)."
|
||||
},
|
||||
{
|
||||
"key": "number",
|
||||
"name_ko": "품셈 번호",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "name",
|
||||
"name_ko": "이름",
|
||||
"unit": "",
|
||||
"visible": true,
|
||||
"note": "뿌리부터 전체 경로(「토공 › 토사깍기 › 기계」) — 잎 이름만으론 겹침."
|
||||
},
|
||||
{
|
||||
"key": "variant_keys",
|
||||
"name_ko": "갈래 키",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "pum_tables",
|
||||
"name_ko": "딸린 품셈 표",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "rule",
|
||||
"name_ko": "계산 규칙",
|
||||
"unit": "",
|
||||
"visible": true,
|
||||
"note": "부모 마디 방식 — `choose_one`(아래 중 하나) · `sum_steps`(아래를 무게대로 더해야 한 단위)."
|
||||
},
|
||||
{
|
||||
"key": "step_weight",
|
||||
"name_ko": "단계 무게",
|
||||
"unit": "",
|
||||
"visible": true,
|
||||
"note": "`sum_steps` 일 때만 — 발파암 절취 0.1 · 깍기 0.9 · 집토 1."
|
||||
},
|
||||
{
|
||||
"key": "effective_date",
|
||||
"name_ko": "적용 시작일",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
}
|
||||
]
|
||||
"name_ko": "건설 공종",
|
||||
"summary": "건설공사 표준품셈 목차가 세울 공종 나무(아직 안 뽑음).",
|
||||
"source": "resources/data_cost_input_value/pum_const_2026.json",
|
||||
"key_prefix": "CW"
|
||||
}
|
||||
]
|
||||
],
|
||||
"columns": [
|
||||
{
|
||||
"key": "work_item_key",
|
||||
"name_ko": "공종 열쇠",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "path_name",
|
||||
"name_ko": "전체 경로 이름",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "name",
|
||||
"name_ko": "이름",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "toc_code",
|
||||
"name_ko": "그 판 목차 코드",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "toc_number",
|
||||
"name_ko": "그 판 목차 번호",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "toc_edition",
|
||||
"name_ko": "목차 판(고시)",
|
||||
"unit": "",
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"key": "level",
|
||||
"name_ko": "단계",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "axis_role",
|
||||
"name_ko": "줄 구실",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "pum_tables",
|
||||
"name_ko": "딸린 품셈 표",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "rule",
|
||||
"name_ko": "계산 규칙",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"key": "step_weight",
|
||||
"name_ko": "단계 비중",
|
||||
"unit": "",
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"values": {
|
||||
"axis_role": {
|
||||
"work_item": "공종 — 품셈 표가 붙은 줄",
|
||||
"group": "묶는 마디 — 표는 없고 아래를 고르는 자리(계산 규칙을 지닌다)",
|
||||
"general_provision": "총칙 — 문서 구역이나 값표가 붙어 B09 가 읽는다",
|
||||
"empty": "빈 잎 — 표도 아래도 없는 자리"
|
||||
},
|
||||
"rule": {
|
||||
"choose_one": "아래 하나를 고름",
|
||||
"sum_steps": "아래를 단계로 다 더함"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "data_work_item_master_manifest",
|
||||
"generated_at": "2026-09-17T12:15:05+09:00",
|
||||
"generated_at": "2026-09-17T13:27:58+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": "762f18dc42a4e6d5512287127e9a21ee0348425aad62b040e29a97be2ec00cdd",
|
||||
"size_bytes": 954642
|
||||
"sha256": "f7658b47d89889207d079f1050cea87f592b97d34995f69272337b064a9d2fb1",
|
||||
"size_bytes": 956887
|
||||
},
|
||||
{
|
||||
"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-17T13:27:59+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": "cbd4174707c4205f4b0ded703ed15d8edaea0c41dbf6077683897afaa2a90cd9",
|
||||
"size_bytes": 3752560
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -65,32 +65,45 @@
|
||||
"name_ko": "건설 공종",
|
||||
"settled": false,
|
||||
"source": "resources/data_cost_input_value/pum_const_2026.json",
|
||||
"toc_source": "⚠ 목차표가 없다 — 장 파일 46개(`source_file` 칸)가 유일한 계층 근거다",
|
||||
"toc_source": "합본 원문 머리 「목 차」 글줄 — 목차표가 없어 랩탑 메인이 글에서 읽음(부문 5 · 장 45 · 1,367줄)",
|
||||
"code_prefix": "CP",
|
||||
"key_prefix": "CW",
|
||||
"code_shape": "CP-<부문2>-<장2>-<절…>",
|
||||
"code_shape_why": "⚠ **부문마다 장 번호가 1부터 다시 시작한다** — 01_공통부문 제1장은 적용기준, 02_토목부문 제1장은 도로포장공사다. 장 번호만으로 코드를 지으면 부문이 다른 공종끼리 덮는다. 산림에서 목차 번호 겹침 둘(12-17-2·12-24-1)이 실제로 서로 덮고 있었다.",
|
||||
"code_shape_why": "⚠ **부문마다 장 번호가 1부터 다시 시작한다** — 공통 1장은 적용기준, 토목 1장은 도로포장공사다. 부문 자리가 없으면 부문이 다른 공종끼리 덮는다(산림에서 목차 번호 겹침 둘이 실제로 덮고 있었다). ⇒ **총칙 뿌리도 부문 자리까지 적는다.**",
|
||||
"general_provision_roots": [
|
||||
"01_공통부문/제1장_적용기준.md"
|
||||
"CP-01-01"
|
||||
],
|
||||
"general_provision_basis": "산림 FP-01 적용기준과 짝. 단위표준·토질·할증 같은 기준표 46개.",
|
||||
"general_provision_basis": "CP-01-01 공통부문 제1장 적용기준 — 줄 37 · 표 46. 산림 FP-01 적용기준과 짝. 단위표준·토질·할증 같은 기준표다.",
|
||||
"general_provision_pending": [
|
||||
{
|
||||
"chapter": "01_공통부문/제8장_건설기계.md",
|
||||
"root": "CP-01-08",
|
||||
"chapter": "공통부문 제8장 건설기계",
|
||||
"rows": 75,
|
||||
"tables": 351,
|
||||
"why": "산림 FP-02 「소요재료 및 기계손료」와 짝으로 보이나, 기계 시공능력·손료 파라미터라 공종으로 볼 여지도 있음. ⬜ 안티그래비티 조사 대기.",
|
||||
"already_extracted": "C0426·C0427 은 `resources/data_machine_productivity/` 로 이미 꺼냈음"
|
||||
},
|
||||
{
|
||||
"chapter": "05_유지관리부문/제1장_공통.md",
|
||||
"root": "CP-05-01",
|
||||
"chapter": "유지관리부문 제1장 공통",
|
||||
"rows": 37,
|
||||
"tables": 40,
|
||||
"why": "이름이 「공통」 — 기준표 장인지 공종 장인지 원문 확인 필요. ⬜ 안티그래비티 조사 대기."
|
||||
"why": "이름이 「공 통」 — 기준표 장인지 공종 장인지 원문 확인 필요. ⬜ 안티그래비티 조사 대기."
|
||||
}
|
||||
],
|
||||
"known_defects": [
|
||||
"표 2,192 중 883(40 %)이 절 이름이 어긋남 — 파서가 글 속 제목을 못 잡음(PLAN 데스크탑서브 「빠진 표 일곱」 절)",
|
||||
"`section` 이 절 번호가 아닌 표 289 — 그 가운데 11 은 값이 박힘"
|
||||
]
|
||||
"형태 미판정 881 / 2,192 — B09 표 읽기가 산림 표로만 검증됨",
|
||||
"밑수 미확보 447 — 곱하면 안 되는 줄이라 받는 쪽이 가려야 함",
|
||||
"갈래 키 비어 있음 — 랩탑 메인이 뒤로 미룸"
|
||||
],
|
||||
"counts_2026": {
|
||||
"rows": 1367,
|
||||
"work_item": 997,
|
||||
"group": 270,
|
||||
"general_provision": 0,
|
||||
"empty": 100,
|
||||
"note": "총칙 표시 전 셈(랩탑 메인 첫 산출). 뿌리를 붙이면 `general_provision` 으로 옮겨 간다 — 줄이 줄지는 않는다."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -399,7 +399,6 @@
|
||||
"FP-12-17": "FW-00385",
|
||||
"FP-12-17-01": "FW-00386",
|
||||
"FP-12-17-02": "FW-00387",
|
||||
"FP-12-17-02#2": "FW-00388",
|
||||
"FP-12-18": "FW-00389",
|
||||
"FP-12-19": "FW-00390",
|
||||
"FP-12-20": "FW-00391",
|
||||
@@ -412,7 +411,6 @@
|
||||
"FP-12-25": "FW-00398",
|
||||
"FP-12-26": "FW-00399",
|
||||
"FP-12-27": "FW-00400",
|
||||
"FP-12-24-01#2": "FW-00401",
|
||||
"FP-12-27-02": "FW-00402",
|
||||
"FP-12-27-03": "FW-00403",
|
||||
"FP-12-28": "FW-00404",
|
||||
@@ -488,7 +486,9 @@
|
||||
"FP-13-16-02": "FW-00474",
|
||||
"FP-14": "FW-00475",
|
||||
"FP-14-01": "FW-00476",
|
||||
"FP-14-02": "FW-00477"
|
||||
"FP-14-02": "FW-00477",
|
||||
"FP-12-17-03": "FW-00388",
|
||||
"FP-12-27-01": "FW-00401"
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
@@ -3206,7 +3206,8 @@
|
||||
"issued_on": "2026-09-17",
|
||||
"first_toc_code": "FP-12-17-02",
|
||||
"first_toc_number": "12-17-2",
|
||||
"first_name": "무근진동기 제외"
|
||||
"first_name": "무근진동기 제외",
|
||||
"relinked": "목차 오기 바로잡음 — 목차 12-17-2(겹친 번호 · 자리 FP-12-17-02#2) → 본문 12-17-3 · 같은 공종이라 열쇠 그대로 (2026-09-17 코덱스 원문 대조 · 브레인 지시 · 손으로 이음)"
|
||||
},
|
||||
"FW-00389": {
|
||||
"issued_edition": "산림청고시제2025-82호",
|
||||
@@ -3297,7 +3298,8 @@
|
||||
"issued_on": "2026-09-17",
|
||||
"first_toc_code": "FP-12-24-01",
|
||||
"first_toc_number": "12-24-1",
|
||||
"first_name": "지수판 설치"
|
||||
"first_name": "지수판 설치",
|
||||
"relinked": "목차 오기 바로잡음 — 목차 12-24-1(겹친 번호 · 자리 FP-12-24-01#2) → 본문 12-27-1 · 같은 공종이라 열쇠 그대로 (2026-09-17 코덱스 원문 대조 · 브레인 지시 · 손으로 이음)"
|
||||
},
|
||||
"FW-00402": {
|
||||
"issued_edition": "산림청고시제2025-82호",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -470,7 +470,8 @@ MERGED_KEYS = (
|
||||
"stone_kind",
|
||||
"masonry_class",
|
||||
"machine_productivity",
|
||||
"work_item_forest", # 2026-09-17 공종 축 산림(건설은 자료가 오면)
|
||||
"work_item_forest", # 2026-09-17 공종 축 산림
|
||||
"work_item_const", # 2026-09-17 공종 축 건설
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -39,7 +39,10 @@ MASTER = OUT_DIR / "work_item_master_2026-01-01.json"
|
||||
|
||||
#: 열쇠를 얹기 **전** 벌(`work_items`)의 지문. 2026-09-17 데스크탑_서브가 뜬 값.
|
||||
#: ⚠ 이 값이 흔들리면 열쇠 작업이 값을 건드렸다는 뜻이다 — 지문을 고치지 말고 원인을 찾을 것.
|
||||
BASELINE_WORK_ITEMS_SHA = "f8b8fba1ab5e6d1d5611b99148eecbed1eaf049c42d7154820f0d9a45e20ffea"
|
||||
#: 2026-09-17 랩탑 메인 — 목차 오기 둘을 본문 번호로 바로잡아(브레인 지시) **네 줄만** 바뀌어 새로 뜸
|
||||
#: (옛 f8b8fba1…). 네 줄 모습은 `test_목차_오기는_본문_번호로_바로잡고_열쇠는_그대로` 가 잼 ·
|
||||
#: 미판정·밑수 목록 파일은 한 글자도 안 바뀜.
|
||||
BASELINE_WORK_ITEMS_SHA = "be88f730b325c290012e5a2a79ad593ce5a74ffd82464226857191fefe6647e3"
|
||||
|
||||
#: 열쇠 작업이 새로 얹은 칸. 지문을 잴 때만 떼어낸다.
|
||||
ADDED_FIELDS = (
|
||||
@@ -130,16 +133,29 @@ def test_열쇠_차례는_목차_차례() -> None:
|
||||
assert nodes[0]["work_item_key"] == format_key(2)
|
||||
|
||||
|
||||
def test_목차_번호가_겹친_줄도_열쇠는_따로(master: dict) -> None:
|
||||
"""⚠ 품셈 목차가 12-17-2·12-24-1 을 두 번 적었음 — 번호를 열쇠로 못 쓰는 실물."""
|
||||
dups = master["toc_duplicate_rows"]
|
||||
assert [d["toc_number"] for d in dups] == ["12-17-2", "12-24-1"]
|
||||
assert [d["slot"] for d in dups] == ["FP-12-17-02#2", "FP-12-24-01#2"]
|
||||
for number in ("12-17-2", "12-24-1"):
|
||||
rows = [it for it in master["work_items"] if it["number"] == number]
|
||||
assert len(rows) == 2
|
||||
assert len({r["work_item_key"] for r in rows}) == 2
|
||||
assert len({r["path_name"] for r in rows}) == 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:
|
||||
@@ -205,18 +221,36 @@ def test_총칙_범위는_코드가_아니라_자료가_정함() -> None:
|
||||
const = load_policy("const")
|
||||
assert const["key_prefix"] == "CW"
|
||||
assert const["settled"] is False
|
||||
assert const["general_provision_roots"] == ["01_공통부문/제1장_적용기준.md"]
|
||||
assert [p["chapter"] for p in const["general_provision_pending"]] == [
|
||||
"01_공통부문/제8장_건설기계.md",
|
||||
"05_유지관리부문/제1장_공통.md",
|
||||
]
|
||||
# ⚠ 건설 뿌리는 **부문 자리까지** — 부문마다 장 번호가 1부터 다시 시작한다.
|
||||
assert const["general_provision_roots"] == ["CP-01-01"]
|
||||
assert [p["root"] for p in const["general_provision_pending"]] == ["CP-01-08", "CP-05-01"]
|
||||
# 뿌리를 바꿔 주면 구실도 따라 바뀐다 — 잣대가 자료에 있다는 증거
|
||||
assert (
|
||||
axis_role({"work_item_code": "FP-09-03", "tables": []}, has_children=True, roots=("FP-09",))
|
||||
axis_role({"work_item_code": "FP-09-03", "tables": []}, has_children=True, general_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"] == 37
|
||||
assert sum(roles.values()) == len(doc["work_items"]) == 1367
|
||||
under = [
|
||||
it
|
||||
for it in doc["work_items"]
|
||||
if it["work_item_code"] == "CP-01-01" or it["work_item_code"].startswith("CP-01-01-")
|
||||
]
|
||||
assert len(under) == 37
|
||||
assert {it["axis_role"] for it in under} == {"general_provision"}
|
||||
# ⬜ 미확정 둘은 아직 공종·묶음으로 서 있다 — 안티그래비티 답이 오면 뿌리에 더한다
|
||||
기계 = next(it for it in doc["work_items"] if it["work_item_code"] == "CP-01-08")
|
||||
assert 기계["axis_role"] != "general_provision"
|
||||
|
||||
|
||||
def test_어느_구실도_지우지_않음() -> None:
|
||||
"""브레인 승인(2026-09-17) — 「덜어내기」가 아니라 「가름만」."""
|
||||
import json as _json
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""건설 공종 축(`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
|
||||
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"))
|
||||
@@ -0,0 +1,64 @@
|
||||
"""공종 축 — **이름과 붙은 표의 절이 어긋나면 빨강** (2026-09-17 브레인 · 코덱스 원문 대조).
|
||||
|
||||
이 병은 한 번 나면 아무도 못 알아챔: 품셈 목차가 같은 번호를 두 번 적자(12-17-2 · 12-24-1)
|
||||
뒤 줄(「무근진동기 제외」)이 앞 표(F0363 「철근, 펌프카 0-15m」)를 물고, 진짜 표(F0364)는 orphan 으로 사라졌음.
|
||||
표 번호로는 안 잡힘(번호는 같았음) — **표 절 제목의 이름이 다른 줄 이름과 똑같은데 그 줄이 아닌 데 붙음**이 그 꼴.
|
||||
⚠ 이름 글자만 조금 다른 자리(목차 「깍기」 · 본문 「깎기」)는 판정하지 않음 — 번호·이름이 딴 줄을 가리킬 때만.
|
||||
⚠ 부록 사례 표 7(F0455·F0456·F0472~F0476)은 번호가 본문 절과 겹쳐 그 절에 붙어 있으나 모두 참조형 —
|
||||
일위대가로 안 읽혀 금액 무관(브레인 전수 판정). 자리 판정은 데스크탑 서브 몫.
|
||||
"""
|
||||
|
||||
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"]
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Z01 공종 축 API 틀 — 산림·건설 두 kind 를 기초단가와 같은 길로(2026-09-17 브레인 · PLAN_공종축 8-4).
|
||||
|
||||
못박는 것: 평평한 잎 목록 · 전체 경로 이름(겹침 없음) · 계산 규칙(choose_one 91 · sum_steps 3) ·
|
||||
불변 열쇠가 오면 `@id` 가 그것으로 · 건설은 자료가 있어야 상자가 섬 · 고치기는 막힘.
|
||||
불변 열쇠가 `@id` · 건설도 같은 길(자료가 있어야 상자가 섬) · 고치기는 막힘.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,7 +13,6 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
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
|
||||
@@ -39,14 +38,16 @@ def _get(client: TestClient, kind: str, **params) -> dict:
|
||||
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"] == 358
|
||||
assert forest["group"] == "work_item" and forest["rows"] == 360
|
||||
|
||||
table = _get(client, "work_item_forest", size=500)
|
||||
assert table["total"] == 358 and table["stats"] == {"leaves": 358, "units": 618}
|
||||
assert table["total"] == 360 and table["stats"] == {"leaves": 360, "units": 620}
|
||||
assert table["editable"] == [] and set(table["locked"]) == set(table["sortable"])
|
||||
rows = table["rows"]
|
||||
assert len({r["@id"] for r in rows}) == 358
|
||||
assert len({r["name"] for r in rows}) == 358 # 잎 이름 35줄 겹침 — 경로로 풀림
|
||||
assert len({r["@id"] for r in rows}) == 360
|
||||
assert (
|
||||
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"] == "토공 › 토사깍기 › 기계"
|
||||
|
||||
@@ -56,19 +57,29 @@ def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> Non
|
||||
assert [s["weight"] for s in blast["steps"]] == ["0.1", "0.9", "1"]
|
||||
assert by_code["FP-09-05-02"]["rule"] == "sum_steps"
|
||||
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"] == 3
|
||||
|
||||
|
||||
def test_불변_열쇠가_오면_id_로_씀_건설도_같은_길(
|
||||
def test_건설_공종_축도_같은_길(client: TestClient) -> None:
|
||||
"""건설(2026-09-17 뽑음) — 부문이 뿌리라 경로가 부문부터 · 열쇠 CW- · 계산 규칙은 전부 choose_one."""
|
||||
table = _get(client, "work_item_const", size=500)
|
||||
assert table["total"] == table["stats"]["leaves"] > 900
|
||||
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:
|
||||
if work_items.doc("work_item_construction") is None: # 빈 상자 안 세움
|
||||
assert "work_item_construction" not in base_prices.KINDS
|
||||
|
||||
folder = tmp_path / "data_work_item_master"
|
||||
folder.mkdir()
|
||||
fake = {
|
||||
@@ -83,24 +94,21 @@ def test_불변_열쇠가_오면_id_로_씀_건설도_같은_길(
|
||||
"variant_keys": ["갑", "을"]},
|
||||
],
|
||||
} # fmt: skip
|
||||
(folder / "work_item_master_const_2026-01-01.json").write_text(
|
||||
(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)
|
||||
monkeypatch.setattr(work_items, "KINDS", tuple(work_items.SOURCE_OF))
|
||||
monkeypatch.setattr(base_prices, "KINDS", (*base_prices.KINDS, "work_item_construction"))
|
||||
|
||||
table = _get(client, "work_item_construction")
|
||||
table = _get(client, "work_item_const")
|
||||
assert [r["@id"] 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"] == {"leaves": 1, "units": 2}
|
||||
assert table["rules"][0]["steps"] == [{"@id": "CW-00002", "weight": "0.5"}]
|
||||
assert not any("불변 열쇠" in line for line in table["notice"])
|
||||
assert _get(client, "work_item_forest")["total"] == 0 # 산림 파일은 이 폴더에 없음
|
||||
|
||||
res = client.put(
|
||||
"/api/master-data/base-prices/work_item_construction/CW-00002",
|
||||
"/api/master-data/base-prices/work_item_const/CW-00002",
|
||||
json={"values": {"name": "바꿈"}},
|
||||
)
|
||||
assert res.status_code == 400 and "공종 축 뼈대" in res.json()["detail"]
|
||||
|
||||
Reference in New Issue
Block a user