Merge remote-tracking branch 'origin/dev' into sub_laptop_2

This commit is contained in:
2026-09-22 02:55:16 +09:00
6 changed files with 174 additions and 17 deletions
+4
View File
@@ -5,6 +5,7 @@
from __future__ import annotations
import sys
from decimal import Decimal
from typing import Any, Literal
@@ -17,6 +18,9 @@ from M01_MasterData import M01_MasterData_Store_Make as make
router = APIRouter(prefix="/api/m01", tags=["M01 MasterData"])
if "pytest" not in sys.modules: # 시험은 사본 폴더를 써서 정본을 미리 읽을 까닭이 없음
store.warm() # 서버가 뜰 때 뒤에서 마스터를 한 번 읽어 둠 — 첫 목록을 기다리지 않게
class CalcBody(BaseModel):
key: str
+9 -2
View File
@@ -68,6 +68,11 @@ def blocks() -> dict[str, list[str]]:
return cache.blocks(FOLDER)
def warm() -> None:
"""서버가 뜰 때 뒤에서 미리 읽어 둠 — 첫 목록을 기다리지 않게."""
cache.warm(FOLDER)
def items_key(data: dict) -> str:
return "" if data.get("그룹") in cm.mf.TABLE_GROUPS else ""
@@ -660,8 +665,10 @@ def save(batch: list[dict]) -> list[dict]:
for part in batch:
_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]
# 고친 줄과 그 줄을 부르던 줄만 검사 — 마스터 전체 검사는 `check_master` 몫
narrowed = cache.narrow(FOLDER, batch, before, after)
old = set(cache.check_saved(before, names, mk.load_book(book_path), narrowed))
new = [x for x in cache.check_saved(after, names, book, narrowed) if x not in old]
if new:
raise StoreError(422, {"errors": new})
if book["다음"] != issued:
@@ -4,6 +4,7 @@
자국(파일 시각·크기) 바뀐 파일만 다시 읽고, 로직 막힘도 미리 세어
목록은 검사를 돌리지 않고 세어 값을 . 고치는 (`calc` · `text` · `save`)
사본을 만지므로 그대로 `cm.load` 새로 읽음.
저장 검사(`check_saved`) 여기 세어 부르기 그림으로 고친 둘레만 (`narrow`).
"""
from __future__ import annotations
@@ -112,3 +113,55 @@ def blocks(folder: Path) -> dict[str, list[str]]:
for key in [k for k, (_, calls) in got.items() if calls & moved]:
got[key] = cm.mf.check_logic(whole, rows[key])
return {key: why for _, got in _BLOCK.values() for key, (why, _) in got.items()}
def graph(folder: Path) -> dict[str, set]:
"""로직 키마다 부르는 로직 키 — 미리 세어 둔 것(저장 때 검사를 좁히는 데 씀)."""
with _FRESH:
blocks(folder)
return {key: calls for _, got in _BLOCK.values() for key, (_, calls) in got.items()}
def narrow(folder: Path, batch: list[dict], before: dict, after: dict):
"""(검사할 로직 키, 안 보는 줄이 부르는 키) — 저장이 건드린 줄과 그 줄을 부르던 줄만.
로직 파일(요소 · ) 섞이면 어느 로직이 흔들릴지 모르므로 None 전부 검사.
"""
if any(after[part["file"]].get("그룹") != "로직" for part in batch):
return None
calls, touched, gone = graph(folder), set(), set()
for part in batch:
was, now = [
{str(r.get("")) for r in side[part["file"]].get("") or []}
for side in (before, after)
]
touched |= now - was # 더한 줄(키는 저장 때 대장이 냄)
gone |= was - now
touched |= {str(c.get("key")) for c in part.get("changes") or [] if c.get("op") != "add"}
only = touched | {key for key, called in calls.items() if called & touched}
return only, {key: called for key, called in calls.items() if key not in gone}
def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> list[str]:
"""저장 전 검사(M01) — 고친 파일의 틀 + 로직의 변수 · 키 + 조합.
`files` = 폴더 전부(고친 ) · `book` = 대장 · `narrowed` = `narrow` 좁힘
(None 이면 전부 검사 마스터 전체 검사는 `check_master` ).
"""
whole = cm.mf.Master(files)
out = [x for name in changed for x in cm.check_form(name, files[name])]
return out + cm.check_logics(files, whole, book, narrowed) + cm.mcb.check_all(files, whole)
def warm(folder: Path) -> threading.Thread:
"""서버가 뜰 때 뒤에서 한 번 읽어 둠 — 켠 뒤 첫 목록이 3초 걸리던 것을 없앰."""
def run() -> None:
try:
blocks(folder)
except Exception: # 마스터가 깨져 있어도 서버 시동을 막지 않음 — 목록에서 다시 드러남
pass
thread = threading.Thread(target=run, name="m01-warm", daemon=True)
thread.start()
return thread
@@ -47,3 +47,19 @@
- 막힘 거름(`blocked=0·1`)·구분·상세구분·찾기 결과와 줄 내용은 그대로 — 시험 `resources/tester/test_m01_cache.py` 다섯이 줄마다 검사한 값과 맞댐.
- 남은 늦은 자리(이 일감 밖) — **저장 자체가 6.7초**: `check_saved` 가 저장 앞뒤로 1,354줄을 다 검사함. 켠 뒤 첫 목록 3.4초도 서버가 뜰 때 미리 한 번 읽어 두면 없앨 수 있음(서버 시동 자리라 이 창 담당 밖).
- 시험 — `test_m01_*` · `test_masterdata_*` 195 통과 · 4 실패. 실패 넷은 모두 `test_m01_combo.py`(정본 조합 파일이 빈 채라고 보는 옛 기대값 — 일감 31 이 견본 조합 `UA000001` 을 정본에 넣어 어긋남) · 이 고침과 무관(첫 실패는 서버를 거치지 않고 정본 파일만 읽음). `check_master` 틀 0 · 본문 0 · 로직 0 · 조합 0 · 단위 14.
## 고친 뒤 2 (일감 33)
남은 늦음 둘 — 잰 자리는 위와 같음(이 창 · 사본 폴더 · `TestClient`).
| 길 | 앞 | 뒤 |
|---|---|---|
| 로직 한 줄 저장(`POST /save`) | 6.8초 | **0.8초** |
| 켠 뒤 첫 목록 | 3.2초 | **0.03초**(미리 읽기 2.7초는 뒤에서 · 기다리지 않음) |
- **저장 검사 좁힘** — 고친 줄과 **그 줄을 부르던 줄**만 검사. 안 보는 줄이 부르는 키는 목록이 미리 세어 둔 것을 그대로 씀(돌고 도는 길 찾기는 그대로 전체 그림으로 봄). 로직 줄만 고쳤으면 품셈재료 연결·인력 준용 검사(`check_links`)도 건너뜀 — 로직 줄이 못 건드리는 자리. 저장 검사(`check_saved`)는 M01 쪽(`_Store_Cache`)으로 옮김 — 좁히는 값이 거기 있고 `check_master` 가 700줄에 닿아서.
- 로직 밖 파일(요소 · 표 · 조합)이 섞인 저장은 어느 로직이 흔들릴지 모르므로 **예전처럼 전부 검사**(그 자리는 그대로 6.8초). 마스터 전체 검사는 `check_master` 몫.
- **미리 읽기** — M01 라우터가 뜰 때 뒤에서 한 번 읽어 둠(`cache.warm` · 데몬 실). 시동을 막지 않고, 시험은 사본 폴더를 쓰므로 건너뜀.
- 저장 뒤에도 잡히는지 — 견본 넷이 모두 422 로 막힘: 없는 로직 키(GC999999) · 없는 인력 요소 키(LB999999) · 돌고 도는 참조 · 고친 줄을 **다른 파일에서** 부르던 줄이 깨짐(입력 하나 더함). 시험 `resources/tester/test_m01_cache.py` 아홉(넷이 이번 것).
- 단위 경고(`check_units`)는 애초부터 저장 검사가 아니라 `check_master 단위` 몫 — 14건 그대로.
- 남은 늦은 자리 — 저장 0.8초 가운데 0.6초는 마스터를 두 번(고치기 전·후) 새로 읽는 것. **요소 파일 저장은 그대로 7.0초**(인력 한 줄 실측): 요소를 가리키는 로직을 키 글자로만 찾으면 원문번호로 가리키는 자리를 놓쳐 저장 검사에 구멍이 나므로 좁히지 않음 — 좁히려면 「요소를 부르는 로직」 지도부터 만들어야 함.
+12 -14
View File
@@ -174,16 +174,6 @@ def master(folder: Path = MASTER) -> mf.Master:
return mf.Master(load(folder=folder))
def check_saved(files: dict[str, dict], changed: list[str], book: dict | None = None) -> list[str]:
"""저장 전 검사(M01) — 고친 파일의 틀 + 모든 로직의 변수 · 키 + 조합.
`files` = 폴더 전부(고친 ) · `book` = 대장.
"""
whole = mf.Master(files)
out = [x for name in changed for x in check_form(name, files[name])]
return out + check_logics(files, whole, book) + mcb.check_all(files, whole)
def calc(files: dict[str, dict], ref: str, given: dict) -> dict:
"""시험 계산(M01) — `ref` = 로직 키 · 멈추면 FormulaError."""
return mf.run(mf.Master(files), ref, given)
@@ -562,17 +552,25 @@ def _elements_into(files: dict[str, dict], by_section: dict) -> None:
# ── (3) 로직 ──────────────────────────────────────────────────────────
def check_logics(files: dict[str, dict], whole: mf.Master, book: dict | None = None) -> list[str]:
out, graph = [], {}
def check_logics(
files: dict[str, dict], whole: mf.Master, book: dict | None = None, narrow=None
) -> list[str]:
"""`narrow` = (검사할 로직 키, 안 보는 줄이 부르는 키) — 주면 그 키만 검사하고
품셈재료 연결 · 인력 준용(`check_links`) 건너뜀(로직 고침이 건드리는 자리)."""
only, graph = narrow or (None, {})
out, graph = [], dict(graph)
for name, data in files.items():
if data.get("그룹") != "로직":
continue
for row in data.get("", []):
key = str(row.get(""))
if only is not None and key not in only:
continue
found, calls = mf.check_logic(whole, row)
out += [f"{name} · {x}" for x in found]
graph[str(row.get(""))] = calls
graph[key] = calls
out += [f"돌고 도는 참조 · {''.join(loop)}" for loop in mf.find_loops(graph)]
return out + duplicate_keys(files, book) + check_links(whole)
return out + duplicate_keys(files, book) + ([] if only else check_links(whole))
def duplicate_keys(files: dict[str, dict], book: dict | None = None) -> list[str]:
+80 -1
View File
@@ -2,13 +2,15 @@
목록(`/logics` · `/combos`) 요청마다 마스터를 새로 읽지 않고 막힘도 미리 세어 값을 .
여기서 보는 = 세어 값이 줄마다 검사한 것과 같음 파일이 바뀌면 바로 다시 읽음
저장 직후 목록에 바로 비침 다른 파일의 로직이 바뀌면 그것을 부르던 줄도 다시 .
저장 직후 목록에 바로 비침 다른 파일의 로직이 바뀌면 그것을 부르던 줄도 다시
저장 검사를 고친 둘레로 좁혀도 (없는 · 없는 요소 · 돌고 도는 참조) 그대로 잡음.
"""
from __future__ import annotations
import json
import shutil
import time
from pathlib import Path
import pytest
@@ -110,3 +112,80 @@ def test_부르는_로직이_바뀌면_다른_파일의_줄도_다시_센다(cli
_write("로직_건설품셈_08장_건설기계.json", add_input) # 부르는 쪽 파일은 그대로
caller = [x for x in _logics(client) if x[""] == CALLER][0]
assert caller["blocked"] is True and any("새입력" in x for x in caller["reasons"])
def _save(client: TestClient, key: str, spoil) -> object:
"""그 로직 줄을 고쳐 저장 길로 보냄 — 저장 검사가 흠을 잡는지 보려고."""
one = client.get("/api/m01/logic", params={"key": key}).json()
row = json.loads(json.dumps(one["logic"], ensure_ascii=False))
spoil(row)
return client.post(
"/api/m01/save",
json={
"files": [
{
"file": one["file"],
"version": one["version"],
"changes": [{"op": "edit", "key": key, "row": row}],
}
]
},
)
def _errors(res) -> list[str]:
assert res.status_code == 422, res.text
return res.json()["detail"]["errors"]
def test_저장_검사를_좁혀도_흠을_잡는다(client: TestClient) -> None:
"""고친 줄과 그 줄을 부르던 줄만 검사하지만 없는 키 · 없는 요소는 그대로 걸림."""
_logics(client) # 미리 세어 둔 값이 선 평소 모양
def no_logic(row: dict) -> None:
at = next(i for i, h in enumerate(row["호표"]) if h["종류"] == "로직")
row["호표"][at]["요소"] = "로직(GC999999, 기계='5401-0017', 지역=지역)"
def no_element(row: dict) -> None:
at = next(i for i, h in enumerate(row["호표"]) if h["종류"] == "인력")
row["호표"][at]["요소"] = "LB999999"
for spoil, word in ((no_logic, "GC999999"), (no_element, "LB999999")):
assert any(word in x for x in _errors(_save(client, CALLER, spoil)))
def test_돌고_도는_참조도_저장이_막는다(client: TestClient) -> None:
_logics(client)
def call_back(row: dict) -> None:
row.setdefault("호표", []).append(
{
"종류": "로직",
"요소": f"로직({CALLER}, 지반구분='토사', 크레인='2104-0010', 지역=지역)",
"이름": "되부르기",
"단위": "시간",
"수량": "1",
}
)
assert any("돌고 도는" in x for x in _errors(_save(client, CALLEE, call_back)))
def test_고친_줄을_부르던_줄이_깨지면_저장이_막힌다(client: TestClient) -> None:
"""좁힌 검사가 「그 줄을 부르는 줄」 을 빠뜨리지 않는지 — 다른 파일에 있는 줄."""
_logics(client)
def add_input(row: dict) -> None:
row["입력"] = [*row.get("입력", []), {"이름": "새입력", "종류": ""}]
assert any("새입력" in x for x in _errors(_save(client, CALLEE, add_input)))
def test_미리_읽어_두면_첫_목록이_기다리지_않는다(client: TestClient) -> None:
store.cache.forget()
thread = store.cache.warm(store.FOLDER)
thread.join(120)
assert not thread.is_alive()
start = time.perf_counter()
assert _logics(client)
assert time.perf_counter() - start < 1.0 # 미리 안 읽으면 2~3초