feat(master_data): 키 개편 — 테이블ID+6자리 키 · 인력·기계 한 테이블 · 장 파일 이름
- 모든 요소·표·로직 줄의 열쇠 → 키(LB000123 꼴) · 옛 열쇠·원문 번호는 원문번호 칸 · 대장 _키대장.json (키 36,840 · 다음 번호)
- 변수 적는 법 키로 — 로직 식·연결·준용·통합·후보·조달 연결 일괄 변환 · {이름} 낀 참조는 ID:원문번호
- 인력 8 파일 → 인력.json(줄마다 조사 · 머리 조사 묶음) · 기계 2 파일 → 기계.json(세부분류)
- 소요량·계수·로직 파일 이름 = 그룹_원문_NN장_장 제목 · 머리 부문·차례
- 엔진(값 찾기 · 알림에 키 옆 이름) · check_master(키 모양·겹침·대장·끊긴 키 · 기계 요소 줄도 본문 결손 대조) · M01 서버(새 줄 키는 대장 다음 번호 · 키 못 고침) · 화면 칸 이름만 맞춤
- 로직 일괄 시험 계산 1,351 줄 — 옮기기 전후 줄마다 결과·금액 같음
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""한 번 돌린 옮기기 — 열쇠 → 키(테이블ID + 6자리) · 인력·기계 한 테이블 · 장 파일 이름 (`_틀.md`).
|
||||
|
||||
옛 변수 `그룹:원문:열쇠` · `찾기(그룹:원문:열쇠, …)` · `로직(원문:열쇠, …)` → 키 · `{이름}` 낀 열쇠는 `ID:원문번호`.
|
||||
표·로직·재료 파일은 글 모양 그대로 두고 바뀐 자리만 갈음 · 인력·기계는 새로 씀.
|
||||
돌리기(옛 틀 파일 · 옛 master_formula(3f4fd66d) 일 때만 — 한 번 돌림): ./venv/Scripts/python.exe resources/master_data/scripts/_migrate_keys.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[2]
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import master_formula as mf # noqa: E402 — 옛 틀 식 읽기
|
||||
import master_keys as mk # noqa: E402
|
||||
from M01_MasterData import M01_MasterData_Store as st # noqa: E402
|
||||
|
||||
MD = mk.MASTER
|
||||
BODY = ROOT / "resources/knowledge/original/원가계산"
|
||||
DIVS = ("공통", "토목", "건축", "기계설비", "유지관리")
|
||||
DIV_DIR = {
|
||||
"공통": "01_공통부문",
|
||||
"토목": "02_토목부문",
|
||||
"건축": "03_건축부문",
|
||||
"기계설비": "04_기계설비부문",
|
||||
"유지관리": "05_유지관리부문",
|
||||
}
|
||||
LABOR = {
|
||||
"건설노임": "건설업",
|
||||
"제조노임": "제조업",
|
||||
"엔지니어링노임": "엔지니어링",
|
||||
"측량노임": "측량",
|
||||
"건설사업관리노임": "건설사업관리",
|
||||
"SW노임": "SW",
|
||||
"산림노임": "산림",
|
||||
"준용대상": "준용대상",
|
||||
}
|
||||
MACHINE = {"건설품셈": "건설품셈", "산림품셈": "산림품셈"}
|
||||
_CHAP = re.compile(r"^(소요량|계수|로직)_(산림품셈|건설품셈)_(\D*)(\d+)장\.json$")
|
||||
_OLDREF = re.compile(r"(인력|재료|기계|소요량|계수|로직|환율|요율):([^:\s·]+):(.+?)(?=\s·\s|$)")
|
||||
_STR = re.compile(r'"(?:[^"\\]|\\.)*"')
|
||||
|
||||
|
||||
def title(book: str, div: str, num: int) -> str:
|
||||
base = BODY / (
|
||||
"산림_표준품셈/본문" if book == "산림품셈" else f"건설공사_표준품셈/본문/{DIV_DIR[div]}"
|
||||
)
|
||||
(hit,) = base.glob(f"제{num:02d}장_*")
|
||||
return hit.name.split("_", 1)[1]
|
||||
|
||||
|
||||
def chapter_order(name: str) -> tuple:
|
||||
m = _CHAP.match(name)
|
||||
book, div, num = m.group(2), m.group(3), int(m.group(4))
|
||||
return (book != "산림품셈", DIVS.index(div) if div else -1, num)
|
||||
|
||||
|
||||
class _Spans(mf._Parser):
|
||||
"""옛 식 읽기 + 요소·표·로직 머리 글자 자리."""
|
||||
|
||||
def __init__(self, text: str):
|
||||
super().__init__(text)
|
||||
self.spans: list[tuple[int, int, str]] = []
|
||||
|
||||
def ref(self, head: bool = False) -> str:
|
||||
at = self.i
|
||||
text = super().ref(head)
|
||||
raw = self.s[at : self.i]
|
||||
start = at + len(raw) - len(raw.lstrip())
|
||||
if start > 0 and self.s[start - 1] == ":": # 앞 그룹 이름까지
|
||||
group = re.search(r"[\w가-힣]+:$", self.s[:start])
|
||||
self.spans.append((group.start(), start + len(text), group.group() + text))
|
||||
else: # 로직(원문:열쇠)
|
||||
self.spans.append((start, start + len(text), "로직:" + text))
|
||||
return text
|
||||
|
||||
|
||||
class Migrate:
|
||||
def __init__(self):
|
||||
self.files = {
|
||||
p.name: json.loads(
|
||||
p.read_text(encoding="utf-8"), parse_float=Decimal, parse_int=Decimal
|
||||
)
|
||||
for p in sorted(MD.glob("*.json"))
|
||||
if not p.name.startswith("_")
|
||||
}
|
||||
self.texts = {p.name: p.read_text(encoding="utf-8") for p in MD.glob("*.json")}
|
||||
self.book = {"다음": {}, "키": {}}
|
||||
self.keys: dict[tuple[str, str, str], str] = {} # (그룹, 원문, 열쇠) → 키
|
||||
self.names: dict[str, str] = {}
|
||||
self.new_name: dict[str, str] = {}
|
||||
self.order: dict[str, int] = {}
|
||||
self.missing: list[str] = []
|
||||
|
||||
# ── 키 주기 ──
|
||||
def plan(self) -> None:
|
||||
chapters = sorted((n for n in self.files if _CHAP.match(n)), key=chapter_order)
|
||||
for group in ("소요량", "계수", "로직"):
|
||||
mine = [n for n in chapters if n.startswith(group + "_")]
|
||||
for i, name in enumerate(mine, start=1):
|
||||
m = _CHAP.match(name)
|
||||
book, div, num = m.group(2), m.group(3), int(m.group(4))
|
||||
self.new_name[name] = f"{group}_{book}_{num:02d}장_{title(book, div, num)}.json"
|
||||
self.order[name] = i
|
||||
for name in self.files:
|
||||
self.new_name.setdefault(name, name)
|
||||
for b in LABOR:
|
||||
self.new_name[f"인력_{b}.json"] = "인력.json"
|
||||
for b in MACHINE:
|
||||
self.new_name[f"기계_{b}.json"] = "기계.json"
|
||||
first = [f"인력_{b}.json" for b in LABOR] + [f"기계_{b}.json" for b in MACHINE]
|
||||
rest = [n for n in self.files if n not in first and not _CHAP.match(n)]
|
||||
for name in first + rest + chapters:
|
||||
data = self.files[name]
|
||||
group, book = data["그룹"], data["원문"]
|
||||
tid = mk.table_id(group, book)
|
||||
for row in self.rows(data):
|
||||
number = str(row["열쇠"])
|
||||
key = mk.issue(self.book, tid, number, self.new_name[name])
|
||||
assert (group, book, number) not in self.keys, (name, number)
|
||||
self.keys[(group, book, number)] = key
|
||||
self.names[key] = str(row.get("이름") or number)
|
||||
|
||||
@staticmethod
|
||||
def rows(data: dict) -> list[dict]:
|
||||
return data.get("표") if data["그룹"] in mf.TABLE_GROUPS else data.get("줄")
|
||||
|
||||
# ── 변수 갈음 ──
|
||||
def ref(self, full: str) -> str:
|
||||
group, book, number = (full.split(":", 2) + ["", ""])[:3]
|
||||
tid = mk.table_id(group, book)
|
||||
if "{" in number:
|
||||
return f"{tid}:{number}"
|
||||
key = self.keys.get((group, book, number))
|
||||
if not key:
|
||||
self.missing.append(full)
|
||||
return full
|
||||
return key
|
||||
|
||||
def formula(self, text):
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
p = _Spans(text)
|
||||
p.parse()
|
||||
out = text
|
||||
for start, end, full in sorted(p.spans, reverse=True):
|
||||
out = out[:start] + self.ref(full) + out[end:]
|
||||
return out
|
||||
|
||||
def link(self, value, group: str, book: str):
|
||||
"""준용·통합 — `그룹:원문:열쇠` 또는 같은 원문 열쇠 → 키."""
|
||||
if value in (None, ""):
|
||||
return value
|
||||
value = str(value)
|
||||
return self.ref(value if ":" in value else f"{group}:{book}:{value}")
|
||||
|
||||
def hint(self, text):
|
||||
"""후보 글 속 `인력:원문:열쇠` → 「키 이름」."""
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
|
||||
def one(m):
|
||||
key = self.ref(m.group(0))
|
||||
return f"{key} {self.names.get(key, '')}".strip()
|
||||
|
||||
return _OLDREF.sub(one, text)
|
||||
|
||||
def row(self, data: dict, row: dict) -> dict:
|
||||
group, book = data["그룹"], data["원문"]
|
||||
out = {"키": self.keys[(group, book, str(row["열쇠"]))], "원문번호": str(row["열쇠"])}
|
||||
if group == "인력":
|
||||
out["조사"] = LABOR[book]
|
||||
if group == "기계":
|
||||
out["세부분류"] = MACHINE[book]
|
||||
for k, v in row.items():
|
||||
if k == "열쇠":
|
||||
continue
|
||||
if group == "로직" and k in ("중간", "덧줄"):
|
||||
v = [{**s, "식": self.formula(s["식"])} for s in v]
|
||||
elif group == "로직" and k == "결과":
|
||||
v = {**v, "식": self.formula(v["식"])}
|
||||
elif group == "로직" and k == "호표":
|
||||
v = [
|
||||
{**h, "수량": self.formula(h["수량"]), "요소": self.formula(h["요소"])}
|
||||
for h in v
|
||||
]
|
||||
elif k in ("준용", "통합"):
|
||||
v = self.link(v, group, book)
|
||||
elif k == "후보":
|
||||
v = self.hint(v)
|
||||
elif k == "연결" and isinstance(v, str):
|
||||
v = self.ref(f"재료:시중물가:{v}")
|
||||
elif k == "값" and book == "시중물가":
|
||||
source = str(v["조달"].get("출처") or "")
|
||||
if source.startswith("나라장터:"):
|
||||
new = "나라장터:" + self.ref("재료:나라장터자재:" + source.split(":", 1)[1])
|
||||
v = {**v, "조달": {**v["조달"], "출처": new}}
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
# ── 쓰기 ──
|
||||
def head(self, name: str, data: dict) -> dict:
|
||||
m = _CHAP.match(name)
|
||||
out = {"그룹": data["그룹"], "원문": data["원문"]}
|
||||
if m and m.group(3):
|
||||
out["부문"] = m.group(3)
|
||||
if m:
|
||||
out["차례"] = Decimal(self.order[name])
|
||||
return {**out, **{k: v for k, v in data.items() if k not in ("표", "줄")}}
|
||||
|
||||
def keep(self, name: str, data: dict, new: dict) -> str:
|
||||
"""글 모양 그대로 — 「열쇠」 칸 · 바뀐 글 · 머리 두 칸만 갈음. 읽어 맞대어 다르면 dump."""
|
||||
text = self.texts[name]
|
||||
rows = self.rows(new)
|
||||
spots = list(re.finditer(r'"열쇠": ("(?:[^"\\]|\\.)*"|[-\d.]+)', text))
|
||||
if len(spots) == len(rows):
|
||||
parts, at = [], 0
|
||||
for m, row in zip(spots, rows):
|
||||
parts += [
|
||||
text[at : m.start()],
|
||||
f'"키": "{row["키"]}", "원문번호": {json.dumps(row["원문번호"], ensure_ascii=False)}',
|
||||
]
|
||||
at = m.end()
|
||||
text = "".join(parts) + text[at:]
|
||||
swap = {}
|
||||
for old, row in zip(self.rows(data), rows):
|
||||
for k in row:
|
||||
if k in old and old[k] != row[k]:
|
||||
for a, b in zip(self.strings(old[k]), self.strings(row[k])):
|
||||
if a != b:
|
||||
swap[json.dumps(a, ensure_ascii=False)] = json.dumps(
|
||||
b, ensure_ascii=False
|
||||
)
|
||||
text = _STR.sub(lambda m: swap.get(m.group(0), m.group(0)), text)
|
||||
extra = {k: v for k, v in new.items() if k in ("부문", "차례")}
|
||||
if extra:
|
||||
add = "".join(
|
||||
f" {json.dumps(k, ensure_ascii=False)}: {st._atom(v)},\n"
|
||||
for k, v in extra.items()
|
||||
)
|
||||
text = re.sub(
|
||||
r'(\n "원문": "[^"]*",\n)', lambda m: m.group(1) + add, text, count=1
|
||||
)
|
||||
if json.loads(text, parse_float=Decimal, parse_int=Decimal) == new:
|
||||
return text
|
||||
print(" 모양 못 지켜 새로 씀:", name)
|
||||
return st.dump(new)
|
||||
|
||||
@classmethod
|
||||
def strings(cls, v):
|
||||
if isinstance(v, str):
|
||||
yield v
|
||||
elif isinstance(v, dict):
|
||||
for x in v.values():
|
||||
yield from cls.strings(x)
|
||||
elif isinstance(v, list):
|
||||
for x in v:
|
||||
yield from cls.strings(x)
|
||||
|
||||
@staticmethod
|
||||
def dump_rows(head: dict, rows: list[dict]) -> str:
|
||||
out = ["{"]
|
||||
for k, v in head.items():
|
||||
out.append(f" {st._atom(k)}: {st.dump(v, 2)},")
|
||||
out.append(' "줄": [')
|
||||
for i, row in enumerate(rows):
|
||||
line = st._flat(row)
|
||||
body = line if len(line) <= 450 else st.dump(row, 4, expand=True)
|
||||
out.append(" " + body + ("," if i < len(rows) - 1 else ""))
|
||||
out += [" ]", "}"]
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
def run(self) -> None:
|
||||
self.plan()
|
||||
written = {}
|
||||
merged = {"인력": ({}, []), "기계": ({}, [])}
|
||||
for name, data in self.files.items():
|
||||
new_rows = [self.row(data, r) for r in self.rows(data)]
|
||||
if data["그룹"] in merged:
|
||||
heads, rows = merged[data["그룹"]]
|
||||
label = LABOR[data["원문"]] if data["그룹"] == "인력" else MACHINE[data["원문"]]
|
||||
heads[label] = {k: v for k, v in data.items() if k not in ("그룹", "줄")}
|
||||
rows += new_rows
|
||||
continue
|
||||
new = {
|
||||
**self.head(name, data),
|
||||
("표" if data["그룹"] in mf.TABLE_GROUPS else "줄"): new_rows,
|
||||
}
|
||||
written[self.new_name[name]] = self.keep(name, data, new)
|
||||
written["인력.json"] = self.dump_rows(
|
||||
{"그룹": "인력", "조사": merged["인력"][0]}, merged["인력"][1]
|
||||
)
|
||||
written["기계.json"] = self.dump_rows(
|
||||
{"그룹": "기계", "세부분류": merged["기계"][0]}, merged["기계"][1]
|
||||
)
|
||||
if self.missing:
|
||||
print("못 찾은 변수", len(self.missing), sorted(set(self.missing))[:30])
|
||||
raise SystemExit(1)
|
||||
for name in self.files:
|
||||
(MD / name).unlink()
|
||||
for name, text in written.items():
|
||||
(MD / name).write_text(text, encoding="utf-8", newline="\n")
|
||||
mk.save_book(mk.finish(self.book))
|
||||
print("파일", len(written), "· 키", len(self.book["키"]), dict(self.book["다음"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
Migrate().run()
|
||||
@@ -2,12 +2,12 @@
|
||||
"""마스터 데이터 검사 도구 (`resources/master_data/_틀.md`).
|
||||
|
||||
돌리기: ./venv/Scripts/python.exe resources/master_data/scripts/check_master.py [틀|본문|로직] [파일 …]
|
||||
./venv/Scripts/python.exe resources/master_data/scripts/check_master.py 계산 "산림품셈:13-4-1 메쌓기" 뒷길이=35 돌=견치돌 …
|
||||
./venv/Scripts/python.exe resources/master_data/scripts/check_master.py 계산 GF000123 뒷길이=35 돌=견치돌 …
|
||||
|
||||
(1) 틀 — 파일 이름 · 머리 칸 · 줄 칸 · 출처 모양
|
||||
(1) 틀 — 파일 이름 · 머리 칸 · 줄 칸 · 출처 모양 · 키 모양·겹침 · 대장
|
||||
(2) 본문 — 표형 요소의 출처 절을 본문 md 에서 찾아 수를 맞댐 · 결손(본문 표에 있고 요소에 없음)·허구(요소에 있고 본문 절에 없음)
|
||||
(3) 로직 — 변수가 가리키는 요소·표·칸 · 찾기 조건 이름 · 로직 입력 · 돌고 도는 참조
|
||||
(4) 계산 — 로직 열쇠와 입력값으로 호표 줄별 수량·단가·금액 · 비목 합 (`master_formula.py`)
|
||||
(3) 로직 — 변수가 가리키는 요소·표·칸 · 찾기 조건 이름 · 로직 입력 · 돌고 도는 참조 · 끊긴 키
|
||||
(4) 계산 — 로직 키와 입력값으로 호표 줄별 수량·단가·금액 · 비목 합 (`master_formula.py`)
|
||||
수 뽑기는 `resources/knowledge/original/_pipeline/pum_md_tool.py` 의 것을 씀.
|
||||
"""
|
||||
|
||||
@@ -27,6 +27,7 @@ sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(ROOT / "resources/knowledge/original/_pipeline"))
|
||||
|
||||
import master_formula as mf # noqa: E402
|
||||
import master_keys as mk # noqa: E402
|
||||
from pum_md_tool import _NUM, md_lines # noqa: E402
|
||||
|
||||
BOOKS = (
|
||||
@@ -55,17 +56,21 @@ DIVISIONS = {
|
||||
"기계설비": "04_기계설비부문",
|
||||
"유지관리": "05_유지관리부문",
|
||||
}
|
||||
_FILE = re.compile(rf"^({'|'.join(mf.GROUPS)})_({'|'.join(BOOKS)})(?:_(\d+장|[^_]+))?\.json$")
|
||||
_FILE = re.compile(
|
||||
rf"^(?:(인력|기계)|({'|'.join(mf.GROUPS)})_({'|'.join(BOOKS)})(?:_(\d\d)장_(.+))?)\.json$"
|
||||
)
|
||||
_SOURCE = re.compile(
|
||||
rf"^(?:자체|(?:{'|'.join(b for b in BOOKS if b not in ('건설품셈', '자체'))})(?:\s\S.*)?|건설품셈 (?:{'|'.join(DIVISIONS)}) \S.*)$"
|
||||
)
|
||||
_IDENT = re.compile(r"\d+-\d+(?:-\d+)*")
|
||||
ROW_KEYS = {
|
||||
"요소": ("열쇠", "이름", "값", "출처"),
|
||||
"표": ("열쇠", "이름", "기준", "출처", "조건", "값칸", "줄", "주"),
|
||||
"로직": ("열쇠", "이름", "결과단위", "출처", "소유", "입력", "중간", "끝수"),
|
||||
"품셈재료": ("열쇠", "이름", "규격", "단위", "요구절", "연결"),
|
||||
"요소": ("키", "원문번호", "이름", "값", "출처"),
|
||||
"표": ("키", "원문번호", "이름", "기준", "출처", "조건", "값칸", "줄", "주"),
|
||||
"로직": ("키", "원문번호", "이름", "결과단위", "출처", "소유", "입력", "중간", "끝수"),
|
||||
"품셈재료": ("키", "원문번호", "이름", "규격", "단위", "요구절", "연결"),
|
||||
}
|
||||
# 한 테이블 파일 — 줄의 갈래 칸 · 머리의 갈래 묶음
|
||||
MERGED = {"인력": "조사", "기계": "세부분류"}
|
||||
|
||||
|
||||
def load(names: list[str] | None = None, folder: Path = MASTER) -> dict[str, dict]:
|
||||
@@ -86,14 +91,14 @@ def master(folder: Path = MASTER) -> mf.Master:
|
||||
return mf.Master(load(folder=folder))
|
||||
|
||||
|
||||
def check_saved(files: dict[str, dict], changed: list[str]) -> list[str]:
|
||||
"""저장 전 검사(M01) — 고친 파일의 틀 + 모든 로직의 변수. `files` = 폴더 전부(고친 뒤)."""
|
||||
def check_saved(files: dict[str, dict], changed: list[str], book: dict | None = None) -> list[str]:
|
||||
"""저장 전 검사(M01) — 고친 파일의 틀 + 모든 로직의 변수 · 키. `files` = 폴더 전부(고친 뒤) · `book` = 키 대장."""
|
||||
out = [x for name in changed for x in check_form(name, files[name])]
|
||||
return out + check_logics(files, mf.Master(files))
|
||||
return out + check_logics(files, mf.Master(files), book)
|
||||
|
||||
|
||||
def calc(files: dict[str, dict], ref: str, given: dict) -> dict:
|
||||
"""시험 계산(M01) — `ref` = 「원문:로직 열쇠」 · 멈추면 FormulaError."""
|
||||
"""시험 계산(M01) — `ref` = 로직 키 · 멈추면 FormulaError."""
|
||||
return mf.run(mf.Master(files), ref, given)
|
||||
|
||||
|
||||
@@ -104,24 +109,43 @@ def _source_ok(source) -> bool:
|
||||
return isinstance(source, str) and bool(_SOURCE.match(source))
|
||||
|
||||
|
||||
def _division(name: str) -> str | None:
|
||||
"""건설품셈 표형 요소·로직 파일의 부문 — 열쇠 앞말(「건축 1-1-1 계수」)."""
|
||||
m = _FILE.match(name)
|
||||
if not m or m.group(2) != "건설품셈" or not m.group(3):
|
||||
return None
|
||||
return next((d for d in DIVISIONS if m.group(3).startswith(d)), None)
|
||||
|
||||
|
||||
def check_form(name: str, data: dict) -> list[str]:
|
||||
def _head(name: str, data: dict, m) -> list[str]:
|
||||
"""머리 — 그룹·원문·판 · 한 테이블은 갈래 묶음 · 장 파일은 차례(건설은 부문)."""
|
||||
out = []
|
||||
m = _FILE.match(name)
|
||||
if not m:
|
||||
out.append(f"{name} · 파일 이름이 「그룹_원문[_장].json」 아님")
|
||||
elif (data.get("그룹"), data.get("원문")) != (m.group(1), m.group(2)):
|
||||
if m.group(1):
|
||||
slot = MERGED[m.group(1)]
|
||||
if data.get("그룹") != m.group(1):
|
||||
out.append(f"{name} · 머리 그룹이 파일 이름과 다름")
|
||||
if not isinstance(data.get(slot), dict) or not data[slot]:
|
||||
out.append(f"{name} · 머리 「{slot}」 묶음 없음")
|
||||
return out
|
||||
if (data.get("그룹"), data.get("원문")) != (m.group(2), m.group(3)):
|
||||
out.append(f"{name} · 머리 그룹·원문이 파일 이름과 다름")
|
||||
for head in ("그룹", "원문", "판"):
|
||||
if head not in data:
|
||||
out.append(f"{name} · 머리 「{head}」 없음")
|
||||
chapter = bool(m.group(4))
|
||||
if (data.get("그룹") in (*mf.TABLE_GROUPS, "로직") and data.get("원문") != "자체") != chapter:
|
||||
out.append(f"{name} · 파일 이름이 「그룹_원문_NN장_장 제목.json」 아님")
|
||||
if chapter and not isinstance(data.get("차례"), Decimal):
|
||||
out.append(f"{name} · 머리 「차례」 없음")
|
||||
if chapter and (data.get("원문") == "건설품셈") != (data.get("부문") in DIVISIONS):
|
||||
out.append(f"{name} · 머리 「부문」 「{data.get('부문')}」")
|
||||
return out
|
||||
|
||||
|
||||
def check_form(name: str, data: dict) -> list[str]:
|
||||
m = _FILE.match(name)
|
||||
if not m:
|
||||
return [
|
||||
f"{name} · 파일 이름이 「그룹_원문[_NN장_장 제목].json」 · 「인력.json」 · 「기계.json」 아님"
|
||||
]
|
||||
out = _head(name, data, m)
|
||||
try:
|
||||
tid = mk.table_id(data.get("그룹"), data.get("원문"))
|
||||
except KeyError:
|
||||
return out + [f"{name} · 테이블ID 없는 그룹·원문"]
|
||||
merged = MERGED.get(data.get("그룹"))
|
||||
table = data.get("그룹") in mf.TABLE_GROUPS
|
||||
kind = "표" if table else "로직" if data.get("그룹") == "로직" else "요소"
|
||||
if data.get("원문") == "품셈재료":
|
||||
@@ -129,16 +153,16 @@ def check_form(name: str, data: dict) -> list[str]:
|
||||
rows = data.get("표" if table else "줄")
|
||||
if not isinstance(rows, list):
|
||||
return out + [f"{name} · 머리 「{'표' if table else '줄'}」 목록 없음"]
|
||||
seen = set()
|
||||
division = _division(name) if kind != "요소" else None
|
||||
division = data.get("부문") if kind != "요소" else None
|
||||
for row in rows:
|
||||
key = str(row.get("열쇠"))
|
||||
where = f"{name} · {key}"
|
||||
if key in seen:
|
||||
out.append(f"{where} · 열쇠 겹침")
|
||||
seen.add(key)
|
||||
if division and not key.startswith(division + " "):
|
||||
out.append(f"{where} · 열쇠 앞에 부문 「{division}」 없음")
|
||||
key, number = str(row.get("키")), str(row.get("원문번호"))
|
||||
where = f"{name} · {key} {row.get('이름') or number}"
|
||||
if not mk.KEY.fullmatch(key) or key[:2] != tid:
|
||||
out.append(f"{where} · 키 모양이 「{tid}」 + 6자리 아님")
|
||||
if merged and row.get(merged) not in data.get(merged, {}):
|
||||
out.append(f"{where} · {merged} 「{row.get(merged)}」 이 머리에 없음")
|
||||
if division and not number.startswith(division + " "):
|
||||
out.append(f"{where} · 원문번호 앞에 부문 「{division}」 없음")
|
||||
for need in ROW_KEYS[kind]:
|
||||
if need not in row:
|
||||
out.append(f"{where} · 칸 「{need}」 없음")
|
||||
@@ -158,7 +182,7 @@ def check_form(name: str, data: dict) -> list[str]:
|
||||
|
||||
|
||||
def _check_linked(where: str, row: dict) -> list[str]:
|
||||
"""품셈재료 — 값 칸 없음 · 요구절은 출처 모양 · 연결 = 시중물가 열쇠 · 후보 조건 · null."""
|
||||
"""품셈재료 — 값 칸 없음 · 요구절은 출처 모양 · 연결 = 시중물가 키 · 후보 조건 · null."""
|
||||
out = [f"{where} · 값 칸을 두지 않음"] if "값" in row else []
|
||||
need = row.get("요구절")
|
||||
if not (isinstance(need, list) and need and all(_source_ok(x) for x in need)):
|
||||
@@ -176,28 +200,25 @@ def _check_linked(where: str, row: dict) -> list[str]:
|
||||
|
||||
|
||||
def check_links(whole: mf.Master) -> list[str]:
|
||||
"""품셈재료 연결 · 시중물가 조달 연결 · 인력 준용·통합이 가리키는 줄."""
|
||||
"""끊긴 키 — 품셈재료 연결 · 시중물가 조달 연결 · 인력 준용·통합·후보가 가리키는 줄."""
|
||||
out = []
|
||||
for key, row in whole.index.get(("재료", "품셈재료"), {}).items():
|
||||
for key, row in whole.index.get("MP", {}).items():
|
||||
link = row.get("연결")
|
||||
if isinstance(link, str) and link not in whole.index.get(("재료", "시중물가"), {}):
|
||||
out.append(f"재료:품셈재료:{key} · 연결 「{link}」 이 시중물가에 없음")
|
||||
for (group, book), rows in whole.index.items():
|
||||
for key, row in rows.items() if group == "인력" else ():
|
||||
for col in ("준용", "통합"):
|
||||
ref = row.get(col)
|
||||
if ref in (None, ""):
|
||||
continue
|
||||
ref = str(ref) if ":" in str(ref) else f"인력:{book}:{ref}"
|
||||
try:
|
||||
whole.get(ref)
|
||||
except mf.FormulaError:
|
||||
out.append(f"인력:{book}:{key} · {col} 「{ref}」 없는 요소")
|
||||
nara = whole.index.get(("재료", "나라장터자재"), {})
|
||||
for key, row in whole.index.get(("재료", "시중물가"), {}).items():
|
||||
if isinstance(link, str) and link not in whole.index.get("MT", {}):
|
||||
out.append(f"{whole.label(key)} · 연결 「{link}」 이 시중물가에 없음")
|
||||
for key, row in whole.index.get("LB", {}).items():
|
||||
for col in ("준용", "통합"):
|
||||
ref = row.get(col)
|
||||
if ref not in (None, "") and str(ref) not in whole.index.get("LB", {}):
|
||||
out.append(f"{whole.label(key)} · {col} 「{ref}」 없는 인력 키")
|
||||
for ref in mk.KEY.findall(str(row.get("후보") or "")):
|
||||
if ref not in whole.index.get("LB", {}):
|
||||
out.append(f"{whole.label(key)} · 후보 「{ref}」 없는 인력 키")
|
||||
nara = whole.index.get("MN", {})
|
||||
for key, row in whole.index.get("MT", {}).items():
|
||||
source = str(row["값"]["조달"].get("출처") or "")
|
||||
if source.startswith("나라장터:") and source.split(":", 1)[1] not in nara:
|
||||
out.append(f"재료:시중물가:{key} · 조달 「{source}」 이 나라장터자재에 없음")
|
||||
out.append(f"{whole.label(key)} · 조달 「{source}」 이 나라장터자재에 없음")
|
||||
return out
|
||||
|
||||
|
||||
@@ -302,7 +323,7 @@ def check_body(files: dict[str, dict]) -> list[str]:
|
||||
out, by_section = [], {}
|
||||
for name, data in files.items():
|
||||
for table in (data.get("표") or []) if data.get("그룹") in mf.TABLE_GROUPS else []:
|
||||
where = f"{name} · {table.get('열쇠')}"
|
||||
where = f"{name} · {table.get('키')} {table.get('원문번호')}"
|
||||
md, ident, text = section_of(str(table.get("출처", "")))
|
||||
if not text:
|
||||
out.append(f"{where} · 본문 절 못 찾음 「{table.get('출처')}」")
|
||||
@@ -319,6 +340,7 @@ def check_body(files: dict[str, dict]) -> list[str]:
|
||||
slot = by_section.setdefault((md, ident), [text, set(), []])
|
||||
slot[1] |= mine
|
||||
slot[2].append(where)
|
||||
_elements_into(files, by_section)
|
||||
for (md, ident), (text, mine, tables) in by_section.items():
|
||||
for (md2, ident2), (_, mine2, tables2) in by_section.items():
|
||||
if md2 == md and ident2.startswith(ident + "-"):
|
||||
@@ -333,30 +355,63 @@ def check_body(files: dict[str, dict]) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _elements_into(files: dict[str, dict], by_section: dict) -> None:
|
||||
"""요소 줄(기계 운전경비처럼 표 대신 요소로 올린 절)의 수도 그 절 결손 대조에 넣음."""
|
||||
seen: dict[str, tuple] = {}
|
||||
for data in files.values():
|
||||
if data.get("그룹") in (*mf.TABLE_GROUPS, "로직"):
|
||||
continue
|
||||
for row in data.get("줄") or []:
|
||||
source = row.get("출처")
|
||||
for text in source.values() if isinstance(source, dict) else [source]:
|
||||
if not isinstance(text, str) or not text.startswith(("건설품셈", "산림품셈")):
|
||||
continue
|
||||
if text not in seen:
|
||||
seen[text] = section_of(text)[:2]
|
||||
slot = by_section.get(seen[text])
|
||||
if slot:
|
||||
slot[1] |= _flat(row.get("값")) | _flat(
|
||||
[row.get(k) for k in ("이름", "규격", "원문번호")]
|
||||
)
|
||||
|
||||
|
||||
# ── (3) 로직 ──────────────────────────────────────────────────────────
|
||||
def check_logics(files: dict[str, dict], whole: mf.Master) -> list[str]:
|
||||
def check_logics(files: dict[str, dict], whole: mf.Master, book: dict | None = None) -> list[str]:
|
||||
out, graph = [], {}
|
||||
for name, data in files.items():
|
||||
if data.get("그룹") != "로직":
|
||||
continue
|
||||
for row in data.get("줄", []):
|
||||
found, calls = mf.check_logic(whole, data.get("원문"), row)
|
||||
found, calls = mf.check_logic(whole, row)
|
||||
out += [f"{name} · {x}" for x in found]
|
||||
graph[f"{data.get('원문')}:{row.get('열쇠')}"] = calls
|
||||
graph[str(row.get("키"))] = calls
|
||||
out += [f"돌고 도는 참조 · {' → '.join(loop)}" for loop in mf.find_loops(graph)]
|
||||
return out + duplicate_keys(files) + check_links(whole)
|
||||
return out + duplicate_keys(files, book) + check_links(whole)
|
||||
|
||||
|
||||
def duplicate_keys(files: dict[str, dict]) -> list[str]:
|
||||
"""같은 그룹·원문에 같은 열쇠 — 먼저 읽힌 파일 것만 찾기에 걸림"""
|
||||
first, out = {}, []
|
||||
def duplicate_keys(files: dict[str, dict], book: dict | None = None) -> list[str]:
|
||||
"""키 겹침(전체) · 원문번호 겹침(테이블 안 — `ID:원문번호` 찾기가 먼저 것만 봄) · 대장 대조."""
|
||||
book = mk.load_book() if book is None else book
|
||||
first, numbers, out = {}, {}, []
|
||||
for name, data in files.items():
|
||||
rows = data.get("표") if data.get("그룹") in mf.TABLE_GROUPS else data.get("줄")
|
||||
for row in rows or []:
|
||||
ref = f"{data.get('그룹')}:{data.get('원문')}:{row.get('열쇠')}"
|
||||
if ref in first and first[ref] != name:
|
||||
out.append(f"{name} · 열쇠 겹침 「{ref}」 — {first[ref]} 에도 있음")
|
||||
first.setdefault(ref, name)
|
||||
key, number = str(row.get("키")), str(row.get("원문번호"))
|
||||
if key in first:
|
||||
out.append(f"{name} · 키 겹침 「{key}」 — {first[key]} 에도 있음")
|
||||
first.setdefault(key, name)
|
||||
if number and (key[:2], number) in numbers:
|
||||
out.append(
|
||||
f"{name} · {key} · 원문번호 겹침 「{number}」 — {numbers[key[:2], number]}"
|
||||
)
|
||||
numbers.setdefault((key[:2], number), key)
|
||||
had = book["키"].get(key)
|
||||
if had is None:
|
||||
out.append(f"{name} · {key} · 대장에 없는 키")
|
||||
elif had["원문번호"] != number:
|
||||
out.append(f"{name} · {key} · 대장 원문번호 「{had['원문번호']}」 와 다름")
|
||||
elif int(key[2:]) >= book["다음"].get(key[:2], 0):
|
||||
out.append(f"{name} · {key} · 대장 다음 번호보다 큼")
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -11,8 +11,11 @@ import itertools
|
||||
import re
|
||||
from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal
|
||||
|
||||
import master_keys as mk
|
||||
|
||||
GROUPS = ("인력", "재료", "기계", "소요량", "계수", "환율", "요율", "로직")
|
||||
TABLE_GROUPS = ("소요량", "계수")
|
||||
TABLE_IDS = tuple(t for t in mk.IDS if mk.GROUP_OF[t] in TABLE_GROUPS)
|
||||
COST_ITEMS = ("노무비", "재료비", "경비")
|
||||
BUNDLE = (*COST_ITEMS, "계")
|
||||
FUNCS = ("만약", "올림", "버림", "반올림", "최소", "최대")
|
||||
@@ -123,8 +126,8 @@ class _Parser:
|
||||
return m.group()
|
||||
|
||||
def ref(self, head: bool = False) -> str:
|
||||
"""열쇠 글자 — 괄호 밖의 `,` · `)` · `.칸` · ` 연산자 ` 앞에서 멈춤(열쇠에 빈칸·괄호가 들어감).
|
||||
head = 찾기·로직 머리 — `,` · `)` 에서만 멈춤(표 이름의 `P.P마대` 같은 점)."""
|
||||
"""키·원문번호 글자 — 괄호 밖의 `,` · `)` · `.칸` · ` 연산자 ` 앞에서 멈춤(원문번호에 빈칸·괄호가 들어감).
|
||||
head = 찾기·로직 머리 — `,` · `)` 에서만 멈춤."""
|
||||
start, depth = self.i, 0
|
||||
while self.i < len(self.s):
|
||||
ch = self.s[self.i]
|
||||
@@ -139,7 +142,7 @@ class _Parser:
|
||||
elif head:
|
||||
pass
|
||||
elif depth == 0 and ch == "." and not self.s[self.i + 1 : self.i + 2].isdigit():
|
||||
# 「K.P」「T.S」 처럼 영문자 사이 점은 열쇠 글자
|
||||
# 「K.P」「T.S」 처럼 영문자 사이 점은 원문번호 글자
|
||||
around = self.s[self.i - 1 : self.i + 2 : 2]
|
||||
if not (len(around) == 2 and around.isascii() and around.isalpha()):
|
||||
break
|
||||
@@ -176,11 +179,11 @@ class _Parser:
|
||||
word = self.name()
|
||||
if word == "찾기":
|
||||
self.need("(")
|
||||
head = self.name()
|
||||
self.need(":")
|
||||
return ("find", f"{head}:{self.ref(True)}", self.args())
|
||||
self.ws()
|
||||
return ("find", self.ref(True), self.args())
|
||||
if word == "로직":
|
||||
self.need("(")
|
||||
self.ws()
|
||||
return ("logic", self.ref(True), self.args())
|
||||
if word in FUNCS:
|
||||
self.need("(")
|
||||
@@ -189,7 +192,9 @@ class _Parser:
|
||||
items.append(self.expr())
|
||||
self.need(")")
|
||||
return ("call", word, items)
|
||||
if word in GROUPS and self.s.startswith(":", self.i):
|
||||
if mk.KEY.fullmatch(word):
|
||||
return ("ref", word)
|
||||
if word in mk.IDS and self.s.startswith(":", self.i): # ID:원문번호 — {이름} 낀 원문번호
|
||||
self.i += 1
|
||||
return ("ref", f"{word}:{self.ref()}")
|
||||
return ("name", word)
|
||||
@@ -222,36 +227,47 @@ def walk(node):
|
||||
|
||||
# ── 마스터 모음 ────────────────────────────────────────────────────────
|
||||
class Master:
|
||||
"""요소·표·로직 찾기 — `files` = {파일 이름: 읽은 JSON}(수는 Decimal)."""
|
||||
"""요소·표·로직 찾기 — `files` = {파일 이름: 읽은 JSON}(수는 Decimal).
|
||||
`ref` = 키(「LB000123」) · 「ID:원문번호」(`{이름}` 을 끼운 원문번호)."""
|
||||
|
||||
def __init__(self, files: dict[str, dict]):
|
||||
self.index: dict[tuple[str, str], dict[str, dict]] = {}
|
||||
self.index: dict[str, dict[str, dict]] = {} # 테이블ID → {키: 줄}
|
||||
self.numbers: dict[str, dict[str, dict]] = {} # 테이블ID → {원문번호: 줄}
|
||||
for data in files.values():
|
||||
rows = data.get("표") if data.get("그룹") in TABLE_GROUPS else data.get("줄")
|
||||
book = self.index.setdefault((data.get("그룹"), data.get("원문")), {})
|
||||
for row in rows or []:
|
||||
book.setdefault(str(row.get("열쇠")), row)
|
||||
key = str(row.get("키"))
|
||||
self.index.setdefault(key[:2], {}).setdefault(key, row)
|
||||
if row.get("원문번호"):
|
||||
self.numbers.setdefault(key[:2], {}).setdefault(str(row["원문번호"]), row)
|
||||
|
||||
def get(self, ref: str) -> dict:
|
||||
group, book, key = (ref.split(":", 2) + ["", ""])[:3]
|
||||
row = self.index.get((group, book), {}).get(key)
|
||||
tid, _, number = ref.partition(":")
|
||||
if number:
|
||||
row = self.numbers.get(tid, {}).get(number)
|
||||
else:
|
||||
row = self.index.get(ref[:2], {}).get(ref)
|
||||
if row is None:
|
||||
raise FormulaError(f"없는 요소 「{ref}」")
|
||||
return row
|
||||
|
||||
def label(self, ref: str) -> str:
|
||||
"""알림 글 — 키 옆에 이름."""
|
||||
try:
|
||||
row = self.get(ref)
|
||||
except FormulaError:
|
||||
return ref
|
||||
return f"{row.get('키')} {row.get('이름') or row.get('원문번호')}"
|
||||
|
||||
def value(self, ref: str):
|
||||
"""요소 값 · 출처 글 — 값이 비면 준용(관리자가 고른 열쇠) → 통합(원문 통합 직종) → 산정(원문 4-라) 차례.
|
||||
준용·통합 = 같은 원문 열쇠 또는 `그룹:원문:열쇠`."""
|
||||
"""요소 값 · 출처 글 — 값이 비면 준용(관리자가 고른 키) → 통합(원문 통합 직종 키) → 산정(원문 4-라) 차례."""
|
||||
row = self.get(ref)
|
||||
if row.get("값") is not None:
|
||||
return row.get("값"), None
|
||||
for col in ("준용", "통합"):
|
||||
alias = row.get(col)
|
||||
if alias not in (None, ""):
|
||||
alias = str(alias)
|
||||
target = self.get(
|
||||
alias if ":" in alias else ":".join(ref.split(":", 2)[:2] + [alias])
|
||||
)
|
||||
target = self.get(str(alias))
|
||||
return target.get("값"), f"{col}: {target.get('이름') or alias}"
|
||||
guess = row.get("산정")
|
||||
if isinstance(guess, dict) and guess.get("값") is not None:
|
||||
@@ -259,13 +275,15 @@ class Master:
|
||||
return None, None
|
||||
|
||||
def logic(self, ref: str) -> dict:
|
||||
return self.get(f"로직:{ref}")
|
||||
if mk.GROUP_OF.get(mk.id_of(ref)) != "로직":
|
||||
raise FormulaError(f"로직 아님 「{ref}」")
|
||||
return self.get(ref)
|
||||
|
||||
|
||||
def fill(template: str, env: dict) -> str:
|
||||
def one(m):
|
||||
if m.group(1) not in env:
|
||||
raise FormulaError(f"열쇠의 {{{m.group(1)}}} 값 없음")
|
||||
raise FormulaError(f"원문번호의 {{{m.group(1)}}} 값 없음")
|
||||
value = env[m.group(1)]
|
||||
return format(value.normalize(), "f") if isinstance(value, Decimal) else str(value)
|
||||
|
||||
@@ -304,10 +322,14 @@ def _match(table: dict, row: dict, cond: str, want) -> bool:
|
||||
return have == want
|
||||
|
||||
|
||||
def _name(row: dict) -> str:
|
||||
return f"{row.get('키')} {row.get('원문번호')}"
|
||||
|
||||
|
||||
def find(table: dict, conds: dict, want: str | None = None) -> dict:
|
||||
for cond in conds:
|
||||
if cond not in table.get("조건", {}):
|
||||
raise FormulaError(f"「{table['열쇠']}」 에 없는 조건 「{cond}」")
|
||||
raise FormulaError(f"「{_name(table)}」 에 없는 조건 「{cond}」")
|
||||
hits = [row for row in table["줄"] if all(_match(table, row, c, v) for c, v in conds.items())]
|
||||
if len(hits) > 1 and table.get("범위규칙", "").split(" · ")[0] in ("이하", "이상·이하"):
|
||||
for c in conds:
|
||||
@@ -317,7 +339,7 @@ def find(table: dict, conds: dict, want: str | None = None) -> dict:
|
||||
if len(hits) > 1 and want: # 여러 줄이면 찾는 값 칸이 있는 줄만
|
||||
hits = [row for row in hits if want in row]
|
||||
if len(hits) != 1:
|
||||
raise FormulaError(f"「{table['열쇠']}」 {conds} 맞는 줄 {len(hits)}개")
|
||||
raise FormulaError(f"「{_name(table)}」 {conds} 맞는 줄 {len(hits)}개")
|
||||
return hits[0]
|
||||
|
||||
|
||||
@@ -332,9 +354,11 @@ def evaluate(node, env: dict, master: Master, depth: int = 0):
|
||||
if kind == "ref":
|
||||
value = element(master, fill(node[1], env), env)[0]
|
||||
if value is None:
|
||||
raise FormulaError(f"「{node[1]}」 값 없음 — 관리자가 채울 값")
|
||||
raise FormulaError(f"「{master.label(fill(node[1], env))}」 값 없음 — 관리자가 채울 값")
|
||||
if isinstance(value, list):
|
||||
raise FormulaError(f"「{node[1]}」 값이 범위 — 입력으로 받아야 함")
|
||||
raise FormulaError(
|
||||
f"「{master.label(fill(node[1], env))}」 값이 범위 — 입력으로 받아야 함"
|
||||
)
|
||||
return value
|
||||
if kind == "field":
|
||||
if node[1][0] == "find":
|
||||
@@ -399,7 +423,7 @@ def _inputs(row: dict, given: dict) -> dict:
|
||||
for spec in row.get("입력", []):
|
||||
name = spec["이름"]
|
||||
if name not in given:
|
||||
raise FormulaError(f"「{row['열쇠']}」 입력 「{name}」 없음")
|
||||
raise FormulaError(f"「{_name(row)}」 입력 「{name}」 없음")
|
||||
value = given[name]
|
||||
if isinstance(value, str): # 화면이 글로 보낸 수 — 고르기 원래 값 · 아니면 수로
|
||||
if "고르기" in spec:
|
||||
@@ -420,7 +444,7 @@ def _inputs(row: dict, given: dict) -> dict:
|
||||
env[name] = value
|
||||
unknown = set(given) - set(env)
|
||||
if unknown:
|
||||
raise FormulaError(f"「{row['열쇠']}」 모르는 입력 {sorted(unknown)}")
|
||||
raise FormulaError(f"「{_name(row)}」 모르는 입력 {sorted(unknown)}")
|
||||
return env
|
||||
|
||||
|
||||
@@ -456,7 +480,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
if price is None and qty == 0:
|
||||
price = Decimal(0) # 안 쓰는 재료 줄 — 값이 비어도 금액 0
|
||||
if price is None:
|
||||
raise FormulaError(f"「{ref}」 값 없음 — 관리자가 채울 값")
|
||||
raise FormulaError(f"「{master.label(ref)}」 값 없음 — 관리자가 채울 값")
|
||||
split = {item["비목"]: qty * price}
|
||||
for k, v in split.items():
|
||||
sums[k] += v
|
||||
@@ -510,7 +534,7 @@ def _choices(row: dict, name: str, master: Master):
|
||||
|
||||
|
||||
def _expand(row: dict, template: str, master: Master):
|
||||
"""열쇠 틀의 {이름} 을 고를 수 있는 값으로 모두 끼움 — 못 정하면 None."""
|
||||
"""원문번호 틀의 {이름} 을 고를 수 있는 값으로 모두 끼움 — 못 정하면 None."""
|
||||
holes = _HOLE.findall(template)
|
||||
options = [_choices(row, h, master) for h in holes]
|
||||
if any(o is None for o in options):
|
||||
@@ -529,7 +553,7 @@ def _check_node(row, node, known: set, master: Master, where: str, out: list, ca
|
||||
if kind in ("ref", "find", "logic"):
|
||||
for hole in _HOLE.findall(part[1]):
|
||||
if hole not in known:
|
||||
out.append(f"{where} · 열쇠 {{{hole}}} 가 입력·중간 아님")
|
||||
out.append(f"{where} · 원문번호 {{{hole}}} 가 입력·중간 아님")
|
||||
if kind == "ref":
|
||||
refs = _expand(row, part[1], master) or []
|
||||
for ref in refs:
|
||||
@@ -539,7 +563,7 @@ def _check_node(row, node, known: set, master: Master, where: str, out: list, ca
|
||||
out.append(f"{where} · {e}")
|
||||
continue
|
||||
if isinstance(link, dict) and link.get("지역") == "입력" and REGION not in known:
|
||||
out.append(f"{where} · 「{ref}」 는 입력 「{REGION}」 필요")
|
||||
out.append(f"{where} · 「{master.label(ref)}」 는 입력 「{REGION}」 필요")
|
||||
if kind == "field" and part[1][0] == "ref":
|
||||
for ref in _expand(row, part[1][1], master) or []:
|
||||
try:
|
||||
@@ -547,9 +571,9 @@ def _check_node(row, node, known: set, master: Master, where: str, out: list, ca
|
||||
except FormulaError:
|
||||
continue
|
||||
if not isinstance(value, dict) or part[2] not in value:
|
||||
out.append(f"{where} · 「{ref}」 에 칸 「{part[2]}」 없음")
|
||||
out.append(f"{where} · 「{master.label(ref)}」 에 칸 「{part[2]}」 없음")
|
||||
if kind == "find":
|
||||
if part[1].split(":")[0] not in TABLE_GROUPS:
|
||||
if mk.id_of(part[1]) not in TABLE_IDS:
|
||||
out.append(f"{where} · 찾기 대상 「{part[1]}」 이 표 아님")
|
||||
continue
|
||||
try:
|
||||
@@ -559,14 +583,14 @@ def _check_node(row, node, known: set, master: Master, where: str, out: list, ca
|
||||
continue
|
||||
for cond, _ in part[2]:
|
||||
if cond not in table.get("조건", {}):
|
||||
out.append(f"{where} · 「{part[1]}」 에 조건 「{cond}」 없음")
|
||||
out.append(f"{where} · 「{master.label(part[1])}」 에 조건 「{cond}」 없음")
|
||||
if kind == "field" and part[1][0] == "find":
|
||||
try:
|
||||
table = master.get(part[1][1])
|
||||
except FormulaError:
|
||||
continue
|
||||
if part[2] not in table.get("값칸", {}):
|
||||
out.append(f"{where} · 「{part[1][1]}」 에 값 칸 「{part[2]}」 없음")
|
||||
out.append(f"{where} · 「{master.label(part[1][1])}」 에 값 칸 「{part[2]}」 없음")
|
||||
if kind == "logic":
|
||||
calls.add(part[1])
|
||||
try:
|
||||
@@ -577,9 +601,9 @@ def _check_node(row, node, known: set, master: Master, where: str, out: list, ca
|
||||
names = {s["이름"] for s in target.get("입력", [])}
|
||||
given = {k for k, _ in part[2]}
|
||||
for extra in sorted(given - names):
|
||||
out.append(f"{where} · 「{part[1]}」 에 입력 「{extra}」 없음")
|
||||
out.append(f"{where} · 「{master.label(part[1])}」 에 입력 「{extra}」 없음")
|
||||
for lack in sorted(names - given):
|
||||
out.append(f"{where} · 「{part[1]}」 입력 「{lack}」 안 줌")
|
||||
out.append(f"{where} · 「{master.label(part[1])}」 입력 「{lack}」 안 줌")
|
||||
if kind == "field" and part[1][0] == "logic":
|
||||
try:
|
||||
target = master.logic(part[1][1])
|
||||
@@ -587,15 +611,15 @@ def _check_node(row, node, known: set, master: Master, where: str, out: list, ca
|
||||
continue
|
||||
if "결과" in target:
|
||||
out.append(
|
||||
f"{where} · 돈 아닌 로직 「{part[1][1]}」 은 수 하나 — 칸 「{part[2]}」 못 씀"
|
||||
f"{where} · 돈 아닌 로직 「{master.label(part[1][1])}」 은 수 하나 — 칸 「{part[2]}」 못 씀"
|
||||
)
|
||||
elif part[2] not in BUNDLE:
|
||||
out.append(f"{where} · 로직 결과 칸 「{part[2]}」 은 {BUNDLE} 가운데 하나여야 함")
|
||||
|
||||
|
||||
def check_logic(master: Master, book: str, row: dict) -> tuple[list[str], set]:
|
||||
"""(문제 목록, 부르는 로직 열쇠) — 변수 · 찾기 조건 · 로직 입력."""
|
||||
out, calls, key = [], set(), f"{book}:{row.get('열쇠')}"
|
||||
def check_logic(master: Master, row: dict) -> tuple[list[str], set]:
|
||||
"""(문제 목록, 부르는 로직 키) — 변수 · 찾기 조건 · 로직 입력."""
|
||||
out, calls, key = [], set(), f"{row.get('키')} {row.get('원문번호')}"
|
||||
known = {s["이름"] for s in row.get("입력", [])}
|
||||
|
||||
def one(text, where, names):
|
||||
@@ -619,7 +643,8 @@ def check_logic(master: Master, book: str, row: dict) -> tuple[list[str], set]:
|
||||
):
|
||||
out.append(f"{where} · 비목 「{item.get('비목')}」")
|
||||
one(item["요소"], where + " 요소", known)
|
||||
if item.get("종류") != "로직" and not str(item["요소"]).startswith(f"{item.get('종류')}:"):
|
||||
group = mk.GROUP_OF.get(mk.id_of(str(item["요소"])))
|
||||
if item.get("종류") != "로직" and group != item.get("종류"):
|
||||
out.append(f"{where} · 요소 그룹이 종류와 다름")
|
||||
names = known | set(COST_ITEMS)
|
||||
for extra in row.get("덧줄", []):
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""마스터 키 — 테이블ID(영문 대문자 2) + 6자리 일련번호 · 대장 `_키대장.json` (`_틀.md` 2장).
|
||||
|
||||
한 번 준 키는 안 바뀜 · 새 줄은 대장의 다음 번호. 원문이 준 번호·옛 열쇠는 줄의 「원문번호」.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
MASTER = Path(__file__).resolve().parent.parent
|
||||
BOOK = MASTER / "_키대장.json"
|
||||
KEY = re.compile(r"[A-Z]{2}\d{6}")
|
||||
|
||||
# (그룹, 원문) → 테이블ID · 인력·기계는 원문 구분 없이 한 테이블
|
||||
TABLES = {
|
||||
("인력", None): "LB",
|
||||
("재료", "시중물가"): "MT",
|
||||
("재료", "나라장터자재"): "MN",
|
||||
("재료", "오피넷유가"): "MO",
|
||||
("재료", "품셈재료"): "MP",
|
||||
("기계", None): "EQ",
|
||||
("소요량", "산림품셈"): "QF",
|
||||
("소요량", "건설품셈"): "QC",
|
||||
("계수", "산림품셈"): "CF",
|
||||
("계수", "건설품셈"): "CC",
|
||||
("환율", "한국은행환율"): "FX",
|
||||
("요율", "조달청제비율"): "RT",
|
||||
("로직", "산림품셈"): "GF",
|
||||
("로직", "건설품셈"): "GC",
|
||||
("로직", "자체"): "GX",
|
||||
}
|
||||
GROUP_OF = {tid: group for (group, _), tid in TABLES.items()}
|
||||
IDS = tuple(GROUP_OF)
|
||||
|
||||
|
||||
def table_id(group: str, book: str | None) -> str:
|
||||
return TABLES.get((group, None)) or TABLES[(group, book)]
|
||||
|
||||
|
||||
def id_of(ref: str) -> str:
|
||||
"""키·「ID:원문번호」 의 테이블ID — 모르면 ""."""
|
||||
return ref[:2] if ref[:2] in GROUP_OF else ""
|
||||
|
||||
|
||||
def load_book(path: Path = BOOK) -> dict:
|
||||
if path.is_file():
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
return {"다음": {}, "키": {}}
|
||||
|
||||
|
||||
def save_book(book: dict, path: Path = BOOK) -> None:
|
||||
"""키 한 줄씩 — 머리 「다음」 뒤에 키 차례대로."""
|
||||
lines = ["{", ' "다음": ' + json.dumps(book["다음"], ensure_ascii=False) + ",", ' "키": {']
|
||||
items = sorted(book["키"].items())
|
||||
for i, (key, v) in enumerate(items):
|
||||
comma = "," if i < len(items) - 1 else ""
|
||||
lines.append(f" {json.dumps(key)}: {json.dumps(v, ensure_ascii=False)}{comma}")
|
||||
lines += [" }", "}"]
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
def issue(book: dict, tid: str, number: str, file: str) -> str:
|
||||
"""(테이블ID, 원문번호) 의 키 — 대장에 있으면 그 키 · 없으면(원문번호 빈 새 줄 포함) 다음 번호를 줌."""
|
||||
index = book.setdefault("_찾기", {})
|
||||
if not index:
|
||||
for key, v in book["키"].items():
|
||||
index[(key[:2], v["원문번호"])] = key
|
||||
key = index.get((tid, number)) if number else None
|
||||
if key:
|
||||
book["키"][key]["파일"] = file
|
||||
return key
|
||||
n = book["다음"].get(tid, 1)
|
||||
key = f"{tid}{n:06d}"
|
||||
book["다음"][tid] = n + 1
|
||||
book["키"][key] = {"원문번호": number, "파일": file}
|
||||
index[(tid, number)] = key
|
||||
return key
|
||||
|
||||
|
||||
def finish(book: dict) -> dict:
|
||||
book.pop("_찾기", None)
|
||||
return book
|
||||
|
||||
|
||||
def stamp(
|
||||
book: dict, tid: str, rows: list[dict], file: str, extra: dict | None = None
|
||||
) -> list[dict]:
|
||||
"""빌더가 만든 줄(「열쇠」 = 원문번호) → 「키」 · 「원문번호」 · extra 칸을 앞에 둔 줄."""
|
||||
out = []
|
||||
for row in rows:
|
||||
row = dict(row)
|
||||
number = str(row.pop("열쇠", row.get("원문번호")))
|
||||
row.pop("원문번호", None)
|
||||
key = row.pop("키", None) or issue(book, tid, number, file)
|
||||
out.append({"키": key, "원문번호": number, **(extra or {}), **row})
|
||||
return out
|
||||
@@ -10,7 +10,7 @@ import re
|
||||
import unicodedata
|
||||
from decimal import Decimal
|
||||
|
||||
LINKED = "재료:품셈재료:"
|
||||
LINKED = "MP" # 품셈재료 테이블ID
|
||||
MARKET = ("가격정보", "물가자료", "유통물가", "물가정보", "거래가격")
|
||||
REGION = "자재지역" # 로직 입력 — 「지역」 은 유가 고르기가 씀
|
||||
_UNITS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"}
|
||||
@@ -33,27 +33,27 @@ def plain_unit(s) -> str:
|
||||
|
||||
def _offers(master: mf.Master, key: str) -> list[tuple[Decimal, str]]:
|
||||
"""시중물가 줄 하나의 (값, 출처) — 시중 다섯 칸 · 조달(나라장터 연결은 그 줄 값)."""
|
||||
value = master.get(f"재료:시중물가:{key}")["값"]
|
||||
value = master.get(key)["값"]
|
||||
out = [
|
||||
(value["시중"][slot], f"재료:시중물가:{key}.시중.{slot}")
|
||||
(value["시중"][slot], f"{key}.시중.{slot}")
|
||||
for slot in MARKET
|
||||
if value["시중"].get(slot) is not None
|
||||
]
|
||||
supply = value["조달"]
|
||||
if supply.get("값") is not None:
|
||||
out.append((supply["값"], f"재료:시중물가:{key}.조달"))
|
||||
out.append((supply["값"], f"{key}.조달"))
|
||||
elif str(supply.get("출처") or "").startswith("나라장터:"):
|
||||
ref = "재료:나라장터자재:" + supply["출처"].split(":", 1)[1]
|
||||
ref = supply["출처"].split(":", 1)[1]
|
||||
if master.get(ref).get("값") is not None:
|
||||
out.append((master.get(ref)["값"], ref))
|
||||
return out
|
||||
|
||||
|
||||
def _market_index(master: mf.Master) -> list[tuple[str, str, str, str, str]]:
|
||||
"""(품명 뿌리, 이름, 규격, 단위, 열쇠) — 「레미콘(관급)-경상북도(울진군)」 뿌리 = 레미콘."""
|
||||
"""(품명 뿌리, 이름, 규격, 단위, 키) — 「레미콘(관급)-경상북도(울진군)」 뿌리 = 레미콘."""
|
||||
if not hasattr(master, "_market"):
|
||||
master._market = []
|
||||
for key, row in master.index.get(("재료", "시중물가"), {}).items():
|
||||
for key, row in master.index.get("MT", {}).items():
|
||||
name = plain(row.get("이름"))
|
||||
root = re.sub(r"\(.*?\)", "", name.split(",")[0]).split("-")[0]
|
||||
master._market.append(
|
||||
@@ -66,7 +66,7 @@ def _candidates(master: mf.Master, ref: str, row: dict, env: dict) -> list[str]:
|
||||
"""후보 조건{이름 · 규격 · 지역: 입력} — 뿌리가 이름으로 끝나고 이름에 지역 글자 · 단위 같음."""
|
||||
cond = row["연결"]
|
||||
if cond.get("지역") == "입력" and REGION not in env:
|
||||
raise mf.FormulaError(f"「{ref}」 입력 「{REGION}」 없음")
|
||||
raise mf.FormulaError(f"「{master.label(ref)}」 입력 「{REGION}」 없음")
|
||||
region = plain(env.get(REGION, "")) if cond.get("지역") == "입력" else ""
|
||||
want, spec, unit = plain(cond["이름"]), plain_spec(cond.get("규격")), plain_unit(row["단위"])
|
||||
hits = [
|
||||
@@ -76,16 +76,16 @@ def _candidates(master: mf.Master, ref: str, row: dict, env: dict) -> list[str]:
|
||||
]
|
||||
hits = [(s, key) for s, key in hits if _offers(master, key)]
|
||||
if not hits:
|
||||
raise mf.FormulaError(f"「{ref}」 지역 값 없음 — {env.get(REGION)}")
|
||||
raise mf.FormulaError(f"「{master.label(ref)}」 지역 값 없음 — {env.get(REGION)}")
|
||||
if len({s for s, _ in hits}) > 1:
|
||||
raise mf.FormulaError(f"「{ref}」 후보 여럿 — 규격 선택 필요 ({len(hits)}줄)")
|
||||
raise mf.FormulaError(f"「{master.label(ref)}」 후보 여럿 — 규격 선택 필요 ({len(hits)}줄)")
|
||||
return [key for _, key in hits]
|
||||
|
||||
|
||||
def element(master: mf.Master, ref: str, env: dict) -> tuple[object, str | None]:
|
||||
"""요소 값과 출처 — 품셈재료는 연결을 따라가 낮은 값 · 그 밖은 줄의 `값` 그대로(출처 None)."""
|
||||
row = master.get(ref)
|
||||
if not ref.startswith(LINKED):
|
||||
if ref[:2] != LINKED:
|
||||
return master.value(ref)
|
||||
link = row.get("연결")
|
||||
if not link:
|
||||
|
||||
Reference in New Issue
Block a user