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:
@@ -19,9 +19,10 @@ SCRIPTS = Path(__file__).resolve().parents[1] / "resources/master_data/scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import check_master as cm # noqa: E402
|
||||
import master_keys as mk # noqa: E402
|
||||
|
||||
FOLDER: Path = cm.MASTER # 시험은 사본 폴더로 바꿈
|
||||
_CHAPTER = re.compile(r"_([^_]*\d+장)\.json$") # 「공통3장」 · 「13장」
|
||||
_CHAPTER = re.compile(r"_(\d\d)장_(.+)\.json$") # 「_03장_토공사.json」
|
||||
# ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
@@ -53,14 +54,18 @@ def items_key(data: dict) -> str:
|
||||
return "표" if data.get("그룹") in cm.mf.TABLE_GROUPS else "줄"
|
||||
|
||||
|
||||
def chapter_of(file: str) -> str:
|
||||
def chapter_of(file: str, data: dict | None = None) -> str:
|
||||
"""「공통 03장 토공사」 · 「13장 구조물」 — 부문은 파일 머리."""
|
||||
m = _CHAPTER.search(file)
|
||||
return m.group(1) if m else ""
|
||||
if not m:
|
||||
return ""
|
||||
division = (data or {}).get("부문") or ""
|
||||
return f"{division} {m.group(1)}장 {m.group(2).replace('_', ' ')}".strip()
|
||||
|
||||
|
||||
def _hit(row: dict, q: str) -> bool:
|
||||
q = q.lower()
|
||||
return not q or q in str(row.get("열쇠", "")).lower() or q in str(row.get("이름", "")).lower()
|
||||
return not q or any(q in str(row.get(k, "")).lower() for k in ("키", "원문번호", "이름"))
|
||||
|
||||
|
||||
def groups() -> list[dict]:
|
||||
@@ -77,19 +82,20 @@ def files_of(group: str) -> list[dict]:
|
||||
if group not in cm.mf.GROUPS:
|
||||
raise StoreError(404, f"없는 그룹 「{group}」")
|
||||
out = []
|
||||
for path in sorted(FOLDER.glob(f"{group}_*.json")):
|
||||
for path in [*FOLDER.glob(f"{group}.json"), *FOLDER.glob(f"{group}_*.json")]:
|
||||
data, version = read(path.name)
|
||||
out.append(
|
||||
{
|
||||
"file": path.name,
|
||||
"book": data.get("원문"),
|
||||
"chapter": chapter_of(path.name),
|
||||
"chapter": chapter_of(path.name, data),
|
||||
"edition": data.get("판"),
|
||||
"rows": len(data.get(items_key(data)) or []),
|
||||
"version": version,
|
||||
"order": data.get("차례") or 0,
|
||||
}
|
||||
)
|
||||
return out
|
||||
return sorted(out, key=lambda f: (f["order"], f["file"]))
|
||||
|
||||
|
||||
def rows(file: str, page: int, size: int, q: str, unlinked: bool = False) -> dict:
|
||||
@@ -112,7 +118,7 @@ def tables(file: str, q: str) -> dict:
|
||||
data, version = read(file)
|
||||
if items_key(data) != "표":
|
||||
raise StoreError(400, f"표형 파일 아님 「{file}」")
|
||||
heads = ("열쇠", "이름", "기준", "출처", "조건", "값칸")
|
||||
heads = ("키", "원문번호", "이름", "기준", "출처", "조건", "값칸")
|
||||
return {
|
||||
"file": file,
|
||||
"version": version,
|
||||
@@ -126,9 +132,9 @@ def tables(file: str, q: str) -> dict:
|
||||
|
||||
def _one(data: dict, key: str) -> dict:
|
||||
for row in data.get(items_key(data)) or []:
|
||||
if str(row.get("열쇠")) == key:
|
||||
if str(row.get("키")) == key:
|
||||
return row
|
||||
raise StoreError(404, f"없는 열쇠 「{key}」")
|
||||
raise StoreError(404, f"없는 키 「{key}」")
|
||||
|
||||
|
||||
def table(file: str, key: str) -> dict:
|
||||
@@ -147,20 +153,20 @@ def logics(book: str, chapter: str, q: str, blocked: int | None) -> list[dict]:
|
||||
whole = cm.mf.Master(files)
|
||||
out = []
|
||||
for name, data in _logic_files(files):
|
||||
if (book and data.get("원문") != book) or (chapter and chapter_of(name) != chapter):
|
||||
if (book and data.get("원문") != book) or (chapter and chapter_of(name, data) != chapter):
|
||||
continue
|
||||
for row in data.get("줄") or []:
|
||||
if not _hit(row, q):
|
||||
continue
|
||||
reasons = cm.mf.check_logic(whole, data.get("원문"), row)[0]
|
||||
reasons = cm.mf.check_logic(whole, row)[0]
|
||||
if blocked is not None and bool(reasons) != bool(blocked):
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"file": name,
|
||||
"book": data.get("원문"),
|
||||
"chapter": chapter_of(name),
|
||||
**{k: row.get(k) for k in ("열쇠", "이름", "결과단위", "출처")},
|
||||
"chapter": chapter_of(name, data),
|
||||
**{k: row.get(k) for k in ("키", "원문번호", "이름", "결과단위", "출처")},
|
||||
"blocked": bool(reasons),
|
||||
"reasons": reasons,
|
||||
}
|
||||
@@ -169,14 +175,13 @@ def logics(book: str, chapter: str, q: str, blocked: int | None) -> list[dict]:
|
||||
|
||||
|
||||
def logic(book: str, key: str) -> dict:
|
||||
"""로직 하나 — 키는 전체에서 하나(`book` 은 화면 호환으로만 받음)."""
|
||||
files = cm.load(folder=FOLDER)
|
||||
for name, data in _logic_files(files):
|
||||
if data.get("원문") != book:
|
||||
continue
|
||||
for row in data.get("줄") or []:
|
||||
if str(row.get("열쇠")) == key:
|
||||
if str(row.get("키")) == key:
|
||||
whole = cm.mf.Master(files)
|
||||
reasons = cm.mf.check_logic(whole, book, row)[0]
|
||||
reasons = cm.mf.check_logic(whole, row)[0]
|
||||
_, version = read(name)
|
||||
return {
|
||||
"file": name,
|
||||
@@ -186,15 +191,18 @@ def logic(book: str, key: str) -> dict:
|
||||
"reasons": reasons,
|
||||
"prices": _prices(whole, row),
|
||||
}
|
||||
raise StoreError(404, f"없는 로직 「{book}:{key}」")
|
||||
raise StoreError(404, f"없는 로직 「{key}」")
|
||||
|
||||
|
||||
def _brief(ref: str, row: dict) -> dict:
|
||||
return {"ref": ref, **{k: row.get(k) for k in ("이름", "규격", "단위", "값", "값칸")}}
|
||||
return {
|
||||
"ref": ref,
|
||||
**{k: row.get(k) for k in ("원문번호", "이름", "규격", "단위", "값", "값칸")},
|
||||
}
|
||||
|
||||
|
||||
def _prices(whole, row: dict) -> dict:
|
||||
"""호표 요소 → 요소 줄 요약(단가 자동 칸) · `{이름}` 낀 열쇠·하위 로직은 계산 때 정해짐.
|
||||
"""호표 요소 → 요소 줄 요약(단가 자동 칸) · `{이름}` 낀 원문번호·하위 로직은 계산 때 정해짐.
|
||||
품셈재료는 연결을 따라간 낮은 값과 출처 · 자재지역 후보는 계산 때 정해짐(값 None)."""
|
||||
out = {}
|
||||
for item in row.get("호표") or []:
|
||||
@@ -205,7 +213,7 @@ def _prices(whole, row: dict) -> dict:
|
||||
except cm.mf.FormulaError:
|
||||
out[ref] = None
|
||||
continue
|
||||
if ref.startswith(cm.mf.LINKED):
|
||||
if ref[:2] == cm.mf.LINKED:
|
||||
try:
|
||||
value, source = cm.mf.element(whole, ref, {})
|
||||
except (cm.mf.FormulaError, KeyError, TypeError):
|
||||
@@ -215,7 +223,7 @@ def _prices(whole, row: dict) -> dict:
|
||||
|
||||
|
||||
def elements(group: str, q: str, limit: int) -> dict:
|
||||
"""요소 찾기 창 — 그룹 전체에서 이름·열쇠 찾기 · `ref` 는 식에 그대로 넣는 `그룹:원문:열쇠`."""
|
||||
"""요소 찾기 창 — 그룹 전체에서 키·원문번호·이름 찾기 · `ref` 는 식에 그대로 넣는 키."""
|
||||
if group not in cm.mf.GROUPS:
|
||||
raise StoreError(404, f"없는 그룹 「{group}」")
|
||||
hits = []
|
||||
@@ -224,26 +232,14 @@ def elements(group: str, q: str, limit: int) -> dict:
|
||||
continue
|
||||
for row in data.get(items_key(data)) or []:
|
||||
if _hit(row, q):
|
||||
ref = f"{group}:{data.get('원문')}:{row.get('열쇠')}"
|
||||
hits.append({**_brief(ref, row), "file": name})
|
||||
hits.append({**_brief(str(row.get("키")), row), "file": name})
|
||||
return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]}
|
||||
|
||||
|
||||
_PICK = {
|
||||
"procure": "재료_나라장터자재.json",
|
||||
"price": "재료_시중물가.json",
|
||||
"job": tuple(
|
||||
f"인력_{b}.json"
|
||||
for b in (
|
||||
"건설노임",
|
||||
"제조노임",
|
||||
"엔지니어링노임",
|
||||
"측량노임",
|
||||
"건설사업관리노임",
|
||||
"SW노임",
|
||||
"산림노임",
|
||||
)
|
||||
),
|
||||
"job": "인력.json",
|
||||
}
|
||||
|
||||
|
||||
@@ -261,27 +257,22 @@ def _lowest(value) -> object:
|
||||
|
||||
|
||||
def pick(kind: str, q: str, limit: int) -> dict:
|
||||
"""고르기 창 — `procure` 나라장터자재(ref = 나라장터:<열쇠>) · `price` 시중물가(관급 조달 줄 포함 · ref = 열쇠)
|
||||
· `job` 값 있는 공표 노임 직종(건설·제조·엔지니어링·측량·건설사업관리·SW·산림)(ref = 인력:원문:열쇠) · 이름·규격 낱말이 모두 들어간 줄."""
|
||||
"""고르기 창 — `procure` 나라장터자재(ref = 나라장터:<키>) · `price` 시중물가(관급 조달 줄 포함 · ref = 키)
|
||||
· `job` 값 있는 공표 노임 직종(준용대상 빼고 모든 조사)(ref = 키) · 이름·규격 낱말이 모두 들어간 줄."""
|
||||
if kind not in _PICK:
|
||||
raise StoreError(404, f"없는 고르기 「{kind}」")
|
||||
words = [_squash(w) for w in q.split()]
|
||||
hits = []
|
||||
names = _PICK[kind] if isinstance(_PICK[kind], tuple) else (_PICK[kind],)
|
||||
books = [read(n)[0] for n in names]
|
||||
rows = [(data.get("원문"), row) for data in books for row in data.get("줄") or []]
|
||||
for book, row in rows:
|
||||
if kind == "job" and row.get("값") is None:
|
||||
for row in read(_PICK[kind])[0].get("줄") or []:
|
||||
if kind == "job" and (row.get("값") is None or row.get("조사") == "준용대상"):
|
||||
continue
|
||||
text = _squash(f"{row.get('이름')} {row.get('규격')}")
|
||||
if all(w in text for w in words):
|
||||
key = str(row.get("열쇠"))
|
||||
key = str(row.get("키"))
|
||||
hits.append(
|
||||
{
|
||||
"ref": {"procure": f"나라장터:{key}", "job": f"인력:{book}:{key}"}.get(
|
||||
kind, key
|
||||
),
|
||||
**{k: row.get(k) for k in ("이름", "규격", "단위")},
|
||||
"ref": f"나라장터:{key}" if kind == "procure" else key,
|
||||
**{k: row.get(k) for k in ("원문번호", "조사", "이름", "규격", "단위")},
|
||||
"값": _lowest(row.get("값")),
|
||||
"관급": kind == "price" and row["값"]["조달"].get("값") is not None,
|
||||
}
|
||||
@@ -290,29 +281,29 @@ def pick(kind: str, q: str, limit: int) -> dict:
|
||||
|
||||
|
||||
# ── 시험 계산 ──────────────────────────────────────────────────────────
|
||||
def _swap(files: dict, book: str, key: str, row: dict, file: str | None) -> None:
|
||||
"""저장 전 시험 — 메모리 사본에서 그 로직을 고친 줄로 바꿈(없으면 `file` 에 더함)."""
|
||||
def _swap(files: dict, key: str, row: dict, file: str | None) -> str:
|
||||
"""저장 전 시험 — 메모리 사본에서 그 로직을 고친 줄로 바꿈(없으면 `file` 에 더함) · 돌려받는 것 = 계산할 키."""
|
||||
row = _dec(row)
|
||||
for name, data in _logic_files(files):
|
||||
if data.get("원문") == book:
|
||||
items = data.get("줄") or []
|
||||
for i, old in enumerate(items):
|
||||
if str(old.get("열쇠")) == key:
|
||||
items[i] = row
|
||||
return
|
||||
items = data.get("줄") or []
|
||||
for i, old in enumerate(items):
|
||||
if str(old.get("키")) == key:
|
||||
items[i] = {**row, "키": key}
|
||||
return key
|
||||
if file not in files or files[file].get("그룹") != "로직":
|
||||
raise StoreError(404, f"없는 로직 파일 「{file}」")
|
||||
files[file].setdefault("줄", []).append(row)
|
||||
new = mk.table_id("로직", files[file].get("원문")) + "999999" # 아직 번호 안 받은 새 로직
|
||||
files[file].setdefault("줄", []).append({**row, "키": new})
|
||||
return new
|
||||
|
||||
|
||||
def calc(book: str, key: str, inputs: dict, row: dict | None = None, file: str | None = None):
|
||||
given = {k: _dec(v) for k, v in inputs.items()}
|
||||
files = cm.load(folder=FOLDER)
|
||||
if row is not None:
|
||||
_swap(files, book, key, row, file)
|
||||
key = str(row.get("열쇠", key))
|
||||
key = _swap(files, str(row.get("키") or key), row, file)
|
||||
try:
|
||||
result = cm.calc(files, f"{book}:{key}", given)
|
||||
result = cm.calc(files, key, given)
|
||||
except (cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
|
||||
return {"ok": False, "reason": str(e) or type(e).__name__}
|
||||
if "결과" in result:
|
||||
@@ -339,25 +330,27 @@ def _dec(v):
|
||||
return v
|
||||
|
||||
|
||||
def _apply(data: dict, changes: list[dict], file: str) -> None:
|
||||
def _apply(data: dict, changes: list[dict], file: str, book: dict) -> None:
|
||||
"""고침 · 더함(키는 대장의 다음 번호 · 원문번호 없으면 빈 글) · 지움 — 키는 바꾸지 않음."""
|
||||
items = data.setdefault(items_key(data), [])
|
||||
tid = mk.table_id(data.get("그룹"), data.get("원문"))
|
||||
for ch in changes:
|
||||
op, key, row = ch.get("op"), ch.get("key"), _dec(ch.get("row"))
|
||||
if op not in ("edit", "add", "delete"):
|
||||
raise StoreError(400, f"모르는 op 「{op}」")
|
||||
if op != "add":
|
||||
at = next((i for i, r in enumerate(items) if str(r.get("열쇠")) == key), None)
|
||||
at = next((i for i, r in enumerate(items) if str(r.get("키")) == key), None)
|
||||
if at is None:
|
||||
raise StoreError(404, f"{file} · 없는 열쇠 「{key}」")
|
||||
if op != "delete" and not (isinstance(row, dict) and "열쇠" in row):
|
||||
raise StoreError(400, f"{file} · row 에 열쇠 없음")
|
||||
new_key = str(row["열쇠"]) if op != "delete" else None
|
||||
taken = {str(r.get("열쇠")) for i, r in enumerate(items) if op == "add" or i != at}
|
||||
if new_key in taken:
|
||||
raise StoreError(400, f"{file} · 열쇠 겹침 「{new_key}」")
|
||||
raise StoreError(404, f"{file} · 없는 키 「{key}」")
|
||||
if op != "delete" and not isinstance(row, dict):
|
||||
raise StoreError(400, f"{file} · row 없음")
|
||||
if op == "add":
|
||||
items.append(row)
|
||||
number = str(row.get("원문번호") or "")
|
||||
rest = {k: v for k, v in row.items() if k not in ("키", "원문번호")}
|
||||
items.append({"키": mk.issue(book, tid, number, file), "원문번호": number, **rest})
|
||||
elif op == "edit":
|
||||
if str(row.get("키")) != key:
|
||||
raise StoreError(400, f"{file} · 키는 바꾸지 않음 「{key}」")
|
||||
items[at] = row
|
||||
else:
|
||||
del items[at]
|
||||
@@ -369,13 +362,19 @@ def save(batch: list[dict]) -> list[dict]:
|
||||
if stale:
|
||||
raise StoreError(409, {"stale": stale})
|
||||
before, after = cm.load(folder=FOLDER), cm.load(folder=FOLDER) # after = 고칠 사본
|
||||
book_path = FOLDER / mk.BOOK.name
|
||||
book = mk.load_book(book_path)
|
||||
issued = dict(book["다음"])
|
||||
names = [part["file"] for part in batch]
|
||||
for part in batch:
|
||||
_apply(after[part["file"]], part.get("changes") or [], part["file"])
|
||||
old = set(cm.check_saved(before, names))
|
||||
new = [x for x in cm.check_saved(after, names) if x not in old]
|
||||
_apply(after[part["file"]], part.get("changes") or [], part["file"], book)
|
||||
mk.finish(book)
|
||||
old = set(cm.check_saved(before, names, mk.load_book(book_path)))
|
||||
new = [x for x in cm.check_saved(after, names, book) if x not in old]
|
||||
if new:
|
||||
raise StoreError(422, {"errors": new})
|
||||
if book["다음"] != issued:
|
||||
mk.save_book(book, book_path)
|
||||
out = []
|
||||
for name in dict.fromkeys(names):
|
||||
old_text = (FOLDER / name).read_bytes().decode("utf-8")
|
||||
@@ -436,10 +435,10 @@ def keep_shape(old_text: str, before: dict, after: dict) -> str | None:
|
||||
nl = "\r\n" if "\r\n" in old_text else "\n"
|
||||
lead = old_text[m.end() : spans[0][0]]
|
||||
indent = len(lead) - len(lead.rstrip(" "))
|
||||
olds = {str(r.get("열쇠")): (r, old_text[s:e]) for r, (s, e) in zip(old_items, spans)}
|
||||
olds = {str(r.get("키")): (r, old_text[s:e]) for r, (s, e) in zip(old_items, spans)}
|
||||
shape, parts = old_text[spans[0][0] : spans[0][1]], []
|
||||
for row in new_items:
|
||||
was, text = olds.get(str(row.get("열쇠")), (None, None))
|
||||
was, text = olds.get(str(row.get("키")), (None, None))
|
||||
shape = text or shape
|
||||
if was == row:
|
||||
parts.append(text)
|
||||
|
||||
Reference in New Issue
Block a user