Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_7

This commit is contained in:
2026-11-01 15:08:50 +09:00
4 changed files with 248 additions and 3 deletions
+12 -3
View File
@@ -17,6 +17,8 @@ from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M01_MasterData import M01_MasterData_Store_Flow as flow
from M01_MasterData import M01_MasterData_Store_Make as make
import master_basis as mbs # noqa: E402 (`store` 가 scripts 를 길에 둠)
router = APIRouter(prefix="/api/m01", tags=["M01 MasterData"])
if "pytest" not in sys.modules: # 시험은 사본 폴더를 써서 정본을 미리 읽을 까닭이 없음
@@ -122,7 +124,9 @@ def get_groups() -> dict:
@router.get("/groups/{group}/files")
def get_files(group: str) -> dict:
return {"files": _call(store.files_of, group)}
"""`판정규` = 머리 판의 해(「2026-01-01」 · 「2026」 → 「2026」) · 읽을 때 파생(파일 안 씀)."""
files = _call(store.files_of, group)
return {"files": [{**f, "판정규": mbs.edition(f["edition"])} for f in files]}
@router.get("/subs")
@@ -156,12 +160,17 @@ def get_tables(
usage: str = "",
section: str = "",
) -> dict:
return _call(store.tables, file, group, sub, detail, q, page, size, usage, section)
out = _call(store.tables, file, group, sub, detail, q, page, size, usage, section)
for t in out["tables"]: # 기준배 · 기준단위 — 읽을 때 파생(정본 표에 안 씀 · 712-3)
t["기준파생"] = _num(mbs.basis(t))
return out
@router.get("/table")
def get_table(file: str, key: str) -> dict:
return _call(store.table, file, key)
"""`기준파생` 은 표 밖 칸 — 표 줄째 되돌려 저장해도 정본에 안 섞임."""
out = _call(store.table, file, key)
return {**out, "기준파생": _num(mbs.basis(out["table"]))}
@router.get("/table/options")
@@ -8,6 +8,7 @@
(2) 본문(`check_master_body.py`) — 표형 요소의 출처 절을 본문 md 에서 찾아 수를 맞댐 · 결손(본문 표에 있고 요소에 없음)·허구(요소에 있고 본문 절에 없음)
(3) 로직 — 변수가 가리키는 요소·표·칸 · 찾기 조건 이름 · 로직 입력 · 재료 고르기 조건 · 돌고 도는 참조 · 끊긴 키
(4) 조합 — 일위대가 조합이 담은 로직 키 · 같은 로직 두 번 · 빈 조합 · 조합을 담음 (`master_combo.py`)
(배) 로직 결과단위 배 ↔ 부르는 표 기준배(10㎡당 표를 원/㎡ 로직이 「/ 10」 없이 씀) (`master_basis.py`)
(5) 계산 — 로직 키와 입력값으로 호표 줄별 수량·단가·금액 · 비목 합 (`master_formula.py`)
수 뽑기는 `resources/knowledge/original/_pipeline/pum_md_tool.py` 의 것을 씀.
"""
@@ -27,6 +28,7 @@ COST = ROOT / "resources/knowledge/original/원가계산"
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(ROOT / "resources/knowledge/original/_pipeline"))
import master_basis as mbs # noqa: E402
import master_combo as mcb # noqa: E402
import master_formula as mf # noqa: E402
import master_keys as mk # noqa: E402
@@ -556,6 +558,8 @@ def main(argv: list[str]) -> int:
report["조합"] = mcb.check_all(files, whole)
if mode in ("단위", "전부"):
report["단위"] = check_units(files, whole)
if mode in ("배", "전부"): # 결과단위 배 ↔ 부르는 표 기준배(읽을 때 파생 · `master_basis`)
report["배"] = mbs.check_times(files)
for title, found in report.items():
print(f"({title}) {len(found)}건")
for line in found:
@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
"""표 기준 → 「기준배 · 기준단위」 — **읽을 때 파생**(정본 표 파일에 쓰지 않음 · PLAN 712-3).
- 표 `기준` 글(「10㎡당」 · 「인/100본당」 · 「ton 당」)을 배 · 단위로 읽음.
- `기준` 이 비었으면 원문 절 머리(「(단위: ㎥당)」 · 「(일당)」)에서
— 절과 아래 절 머리가 한 꼴일 때만.
- 단위가 아닌 기준(「인력품」 · 「할증」 · 「정의」)은 `갈래` 「비단위」 · 못 읽음은 「모름」.
- 배 대조(`check_times`) = 로직 결과단위 배(원/10㎡ → 10) ↔ 그 로직이 부르는 표 기준배
(`check_master` 「배」).
"""
from __future__ import annotations
import re
from decimal import Decimal
from functools import lru_cache
from check_master_body import section_of
# ponytail: 단위 별칭은 여기 짧은 표 — 712-2 단위 모듈이 서면 그쪽 별칭으로 갈아 끼움
_ALIAS = {"ea": "개", "톤": "ton", "t": "ton", "㎞": "km", "hr": "시간", "min": "분", "㎏": "kg"}
_UNITS = sorted(
(
"㎡ ㎥ ㎢ ㎞ km ㎜ mm ㎝ cm m ha ton 톤 t kg ㎏ 개소 개 본 대 회 조 기 틀 "
"일 시간 hr 분 min 초 매 주 도엽 도곽 필지 점 발파 인 노선 공 경간 련 식 "
"개월 월 년 가닥 면 장 측점 세트 set 호 층 단계 ea"
).split(),
key=len,
reverse=True,
)
_UNIT_AT_END = re.compile(
rf"(?<![\d:,.A-Za-z])(\d[\d,]*(?:\.\d+)?)?\s*({'|'.join(map(re.escape, _UNITS))})$"
)
_HANGUL = re.compile(r"[가-힣]")
_HEAD = re.compile(r"^\(\s*(?:단위\s*:\s*)?([^()]*당[^()]*)\)$") # 원문 머리 줄 「(단위: ㎥당)」
_RESULT = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*)?\s*([^\s·(\d,.][^\s·(]*)") # 「원/10㎡」
def unit(text: str) -> str:
return _ALIAS.get(text, text)
def _times(text: str | None) -> Decimal:
return Decimal(text.replace(",", "")) if text else Decimal(1)
def parse(text) -> dict | None:
"""기준 글 → `{배, 단위}` · 단위 아닌 글이면 None."""
core = re.split(r"\s+[—-]\s+", str(text or "").strip())[0]
core = re.sub(r"\([^()]*\)", "", core).strip()
if not core or core.startswith("단위"): # 「단위: 인」 = 값 칸 단위(기준 아님)
return None
if re.search(
r",\s|[∼~]", core
): # 「구하는점 5점, 주어진점 3점」 · 「400필지∼450필지」 = 조건 글
return None
core = core.rsplit("/", 1)[-1].strip() # 「인/100본당」 → 「100본당」(자원/기준)
if "당" in core: # 「1필지당 1,500㎡」 · 「1대당 높이 2m 기준」 → 「당」 앞까지
core = core[: core.rfind("당")].strip()
m = _UNIT_AT_END.search(core)
if not m:
return None
one, at = m.group(2), m.start(2)
if len(one) == 1 and _HANGUL.match(one) and _HANGUL.match(core[at - 1 : at] or " "):
return None # 「참조」 의 「조」 — 한 글자 한글 단위가 낱말 끝이면 단위 아님
return {"배": _times(m.group(1)), "단위": unit(m.group(2))}
@lru_cache(maxsize=None)
def _heads(source: str) -> tuple[str, ...]:
"""원문 절(과 아래 절) 머리 줄 글 — 차례대로 · 겹침 뺌."""
_, _, text = section_of(source)
found = (_HEAD.match(line.strip()) for line in text.split("\n"))
return tuple(dict.fromkeys(m.group(1).strip() for m in found if m))
def basis(table: dict) -> dict:
"""표 하나의 파생 칸 — `{기준배, 기준단위, 기준근거, 갈래}`(갈래 = 단위 · 비단위 · 모름)."""
said = str(table.get("기준") or "").strip()
if said:
got = parse(said)
if got:
return {"기준배": got["배"], "기준단위": got["단위"], "기준근거": "표", "갈래": "단위"}
return {"기준배": None, "기준단위": None, "기준근거": "표", "갈래": "비단위"}
heads = [h for h in _heads(str(table.get("출처") or "")) if parse(h)]
picks = {(parse(h)["배"], parse(h)["단위"]) for h in heads}
if len(picks) == 1: # 절 머리가 한 꼴일 때만 — 둘 넘으면 손 확인
times, one = picks.pop()
return {
"기준배": times,
"기준단위": one,
"기준근거": f"원문 「{heads[0]}」",
"갈래": "단위",
}
return {"기준배": None, "기준단위": None, "기준근거": None, "갈래": "모름"}
def result_per(result_unit) -> list[tuple[Decimal, str]]:
"""로직 결과단위 분모 — 「원/10㎡」 → [(10, ㎡)] · 여럿이면 여럿."""
return [(_times(n), unit(u)) for n, u in _RESULT.findall(str(result_unit or ""))]
def _formula_text(row: dict) -> str:
return str([row.get(k) for k in ("중간", "호표", "덧줄")])
def _divides(text: str, times: Decimal) -> bool:
"""식이 그 배로 나누거나 곱하는가 — 「/ 10」 · 「÷10」 · 「* 0.1」."""
n = format(times.normalize(), "f")
inverse = format((1 / times).normalize(), "f")
pattern = rf"(?:[/÷]\s*{re.escape(n)}|\*\s*{re.escape(inverse)})(?![\d.])"
return re.search(pattern, text) is not None
def check_times(files: dict[str, dict]) -> list[str]:
"""배 어긋남 — 로직이 부르는 표(`용도.로직키`)의 기준배 ≠ 1 인데 결과단위가 같은 배가 아니고
식에서도 그 배로 나누지 않는 짝(시스템 관리자 원문 확인 거리)."""
logic_rows = {
str(r.get("키")): r
for d in files.values()
if d.get("그룹") == "로직"
for r in d.get("줄") or []
}
out = []
for name, data in files.items():
for table in data.get("표") or []:
got = basis(table)
if got["갈래"] != "단위" or got["기준배"] == 1:
continue
for key in (table.get("용도") or {}).get("로직키") or []:
row = logic_rows.get(str(key))
if row is None:
continue
per = result_per(row.get("결과단위"))
shown = format(got["기준배"].normalize(), "f")
said = str(row.get("결과단위") or "") # 「원/보양길이 100m」
if (
(got["기준배"], got["기준단위"]) in per
or f"{shown}{got['기준단위']}" in said
or _divides(_formula_text(row), got["기준배"])
):
continue
out.append(
f"{key} · 결과단위 「{row.get('결과단위')}」 ↔ 표 {table.get('키')}"
f" 기준 「{shown}{got['기준단위']}」 — 배 어긋남"
)
return out
def edition(text) -> str:
"""파일 머리 판 → 해(「2026-01-01」 · 「2026」 → 「2026」) · 해 없으면 그대로(파일 안 씀)."""
m = re.match(r"\s*(\d{4})(?:\D|$)", str(text or ""))
return m.group(1) if m else str(text or "")
+79
View File
@@ -0,0 +1,79 @@
"""712-3 지킴 — 표 기준 → 기준배 · 기준단위 읽을 때 파생 · 배 대조 · 정본 안 씀(`master_basis`)."""
from __future__ import annotations
import json
import sys
from decimal import Decimal
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "resources/master_data/scripts"))
sys.path.insert(0, str(ROOT))
import check_master as cm # noqa: E402
import master_basis as mbs # noqa: E402
def _p(text):
got = mbs.parse(text)
return None if got is None else (got["배"], got["단위"])
def test_parse_forms():
assert _p("10㎡당") == (Decimal(10), "㎡")
assert _p("인/100본당") == (Decimal(100), "본")
assert _p("ton 당") == _p("톤당") == _p("1t") == (Decimal(1), "ton")
assert _p("1,000본당") == (Decimal(1000), "본")
assert _p("1필지당 1,500㎡") == (Decimal(1), "필지")
assert _p("일당 — 편성 1조 기준") == (Decimal(1), "일")
assert _p("세면기 개당") == (Decimal(1), "개")
assert _p("바닥면적㎡당") == (Decimal(1), "㎡")
for text in (
"인력품",
"정의",
"할증",
"참조",
"Joint당",
"단위: 인",
"구하는점 5점, 주어진점 3점",
):
assert _p(text) is None, text
def test_fill_ratio_and_no_write():
files = cm.load()
before = json.dumps(files, default=str, ensure_ascii=False)
called = [
mbs.basis(t)
for d in files.values()
for t in d.get("표") or []
if (t.get("용도") or {}).get("로직키")
]
unit = sum(1 for b in called if b["갈래"] == "단위")
from_body = sum(1 for b in called if str(b["기준근거"] or "").startswith("원문"))
assert unit / len(called) >= 0.78 # 로직이 부르는 표 2,023 중 1,601(79 %)
assert from_body >= 280 # 빈 기준 579 중 원문 머리로 채움 285
assert json.dumps(files, default=str, ensure_ascii=False) == before # 읽기만
def test_times_mismatch_list():
"""배 어긋남 = 시스템 관리자 원문 확인 거리 — 늘거나 줄면 PLAN 712-3 목록도 고침."""
found = {line.split(" · ")[0] for line in mbs.check_times(cm.load())}
known = {"GC000482", "GC000483", "GC000484", "GC000485", "GF000081"}
assert found == known | {"GC000522", "GC000524"}
def test_edition():
assert mbs.edition("2026-01-01") == mbs.edition("2026") == "2026"
assert mbs.edition(None) == ""
def test_router_basis_outside_table():
from M01_MasterData import M01_MasterData_Router as router
got = router.get_table("소요량_산림품셈_13장_구조물.json", "QF000443")
assert got["기준파생"]["기준배"] == 10 and got["기준파생"]["기준단위"] == "㎡"
assert "기준배" not in got["table"] and "기준파생" not in got["table"]
listed = router.get_tables(file="소요량_산림품셈_13장_구조물.json", q="QF000443")
assert listed["tables"][0]["기준파생"]["기준배"] == 10