From 3b94c5e6a534f30da64ef0e36d8114e5478913b7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 22 Sep 2026 01:44:44 +0900 Subject: [PATCH 1/9] =?UTF-8?q?fix(M01):=20=EC=9E=AC=EB=A3=8C=20=EC=B2=98?= =?UTF-8?q?=EC=9D=8C=20=ED=8E=BC=EC=B9=98=EB=A9=B4=20=EC=9E=90=EC=9E=AC?= =?UTF-8?q?=ED=92=88=EB=AA=A9=20=EB=AA=A9=EB=A1=9D=20=EB=B0=94=EB=A1=9C=20?= =?UTF-8?q?=EC=97=B4=EB=A6=BC=20=C2=B7=20=EC=A2=81=EC=9D=80=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=84=93=EC=9D=80=20=ED=91=9C=20=EC=95=88=EB=82=B4?= =?UTF-8?q?=20=ED=95=9C=20=EC=A4=84=20(=EC=9D=BC=EA=B0=90=2030)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ --- M01_MasterData/M01_MasterData_UI_Page.ts | 7 ++++++- M01_MasterData/M01_MasterData_UI_Side.ts | 12 +++++++++++- M01_MasterData/M01_MasterData_UI_Style.css | 13 +++++++++++++ resources/master_data/ref/_검증_전체화면.md | 3 +++ ui_template/ui_template_locale_m1.ts | 1 + 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index 08f2e216..dd12bd71 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -80,7 +80,12 @@ function buildPage(): HTMLElement { const comboHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); const elementView = el("div", { className: "m01-master__elements", - children: [head, notice, body], + children: [ + head, + notice, + el("p", { className: "m01-master__hint", text: L("M01_WideHint") }), + body, + ], }); const showNotice = (nodes: (HTMLElement | string)[]): void => { diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index 4d586ae9..e379f5b4 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -379,6 +379,16 @@ export function buildSide( ); }; + /** 처음 펼칠 때 열 줄 — 재료는 차례 첫 파일(자재품목) */ + const firstRun = (group: Group): string => { + if (group !== "재료") return `${group}|`; + const first = [...(lists.get(group) ?? [])].sort((a, b) => order(a.file) - order(b.file))[0]; + return first + ? `${group} +${first.file}` + : ""; + }; + const refresh = async (group: string): Promise => { const list = await fetchFiles(group); if (list[0] && (group === "인력" || group === "기계")) { @@ -427,7 +437,7 @@ export function buildSide( if (!root.classList.contains("is-collapsed")) return; // 닫을 때는 그대로 둠 restoring = true; try { - runs.get(lastRun.get(group) ?? (group === "재료" ? "" : `${group}|`))?.(); + runs.get(lastRun.get(group) ?? firstRun(group))?.(); } finally { restoring = false; } diff --git a/M01_MasterData/M01_MasterData_UI_Style.css b/M01_MasterData/M01_MasterData_UI_Style.css index 7da36a04..2df3b62e 100644 --- a/M01_MasterData/M01_MasterData_UI_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Style.css @@ -139,6 +139,19 @@ margin: 0; } +.m01-master__hint { + display: none; + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +@media (max-width: 600px) { + .m01-master__hint { + display: block; + } +} + .m01-master__body { display: flex; flex-direction: column; diff --git a/resources/master_data/ref/_검증_전체화면.md b/resources/master_data/ref/_검증_전체화면.md index 1e99b08c..e642569b 100644 --- a/resources/master_data/ref/_검증_전체화면.md +++ b/resources/master_data/ref/_검증_전체화면.md @@ -39,3 +39,6 @@ ORCA · 포트 8006/5179 · 넓은 화면 928px · 좁은 화면 390px(iframe). 3. 넓은 표(요율 · 기계 · 재료)는 390px에서 안쪽 스크롤 — 열 줄이기 없음. 4. 일위대가 조합 빈 화면. 5. 시험 흔적 없음(값 안 바꿈). + +## 고친 뒤 +- 재료 처음 펼침 : 「재료 · 자재품목」 30,592줄 목록이 바로 열림. 좁은 화면(600px 이하) 표 위에 「표가 넓음 — 옆으로 밀어서 볼 것」 한 줄(요율 · 소요량 확인) · 가로 스크롤 유지, 넘침 0. 자재품목 612쪽은 찾기 칸이 있어 그대로 둠. diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index bb250757..5a3e6fad 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -11,6 +11,7 @@ export const ui_locales_m1 = { M01_LoadFailed: ["마스터 요소를 못 읽음", "Failed to load master elements"], M01_PickFile: ["왼쪽에서 그룹과 파일을 고를 것", "Pick a group and a file on the left"], M01_Search: ["이름 찾기", "Find by name"], + M01_WideHint: ["표가 넓음 — 옆으로 밀어서 볼 것", "Wide table — swipe sideways"], M01_Prev: ["이전", "Prev"], M01_Next: ["다음", "Next"], M01_Page: ["쪽", "pages"], From e3e8fc15e273ca4ab6e78a6e15a5e963be1cc59c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 22 Sep 2026 01:51:48 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(M01):=20=EA=B2=AC=EB=B3=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=ED=95=98=EB=82=98=EB=A5=BC=20=EC=A0=95=EB=B3=B8?= =?UTF-8?q?=EC=97=90=20=EB=84=A3=EC=9D=8C=20=E2=80=94=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=97=90=20=EA=B2=AC=EB=B3=B8=20=ED=91=9C=EC=8B=9C=20=C2=B7=20?= =?UTF-8?q?=EB=8B=B4=EC=9D=80=20=EB=A1=9C=EC=A7=81=20=EC=85=8B=20=C2=B7=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EA=B8=B8=EB=A1=9C=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?(=EC=9D=BC=EA=B0=90=2031)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ --- resources/master_data/_키대장.json | 5 +++-- resources/master_data/일위대가조합.json | 14 +++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/resources/master_data/_키대장.json b/resources/master_data/_키대장.json index 1e5e5a84..f64fde1c 100644 --- a/resources/master_data/_키대장.json +++ b/resources/master_data/_키대장.json @@ -1,5 +1,5 @@ { - "다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30600, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 252, "CC": 341, "GC": 1104, "QC": 2060, "GX": 1}, + "다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30600, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 252, "CC": 341, "GC": 1104, "QC": 2060, "GX": 1, "UA": 2}, "폐기": {"MT000001": "자재품목 휘발유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000002": "자재품목 경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000003": "자재품목 선박용경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000004": "자재품목 중유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000005": "자재품목 등유 — 값이 비고 글뿐 · 유가 테이블로 감", "LB000399": "이름 「〃」 — 직종이 아니라 되풀이 기호 · 소요량 13-6-9 에서 윗줄 직종으로 바로잡음", "MN": "재료_나라장터자재 — old 로 옮김 · 로직은 자재품목·품셈재료로 갈아 끼움", "LB000372": "미확보 고급기능사 — 측량노임 항공사진고급기능사 옛이름으로 옮김", "LB000393": "미확보 리베팅공 — 건설노임 철골공 옛이름으로 옮김(2010 통합표 연번10)", "LB000405": "미확보 Belt Conveyor 설치공 — 품셈 13-10-1 주② 배분으로 다섯 직종에 나눔", "MT000006": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "MT000007": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "LB000368": "미확보 기술사 — 기술사(건설) 옛이름으로 옮김", "LB000369": "미확보 특급기술자 — 특급기술자(건설) 옛이름으로 옮김", "LB000370": "미확보 고급기술자 — 고급기술자(건설) 옛이름으로 옮김", "LB000361": "미확보 중급기술자 — 중급기술자(건설) 옛이름으로 옮김", "LB000364": "미확보 초급기술자 — 초급기술자(건설) 옛이름으로 옮김", "LB000365": "미확보 고급숙련기술자 — 고급숙련기술자(건설) 옛이름으로 옮김", "LB000366": "미확보 중급숙련기술자 — 중급숙련기술자(건설) 옛이름으로 옮김", "LB000367": "미확보 초급숙련기술자 — 초급숙련기술자(건설) 옛이름으로 옮김", "LB000389": "미확보 기계기사 — 초급기술자(기계·설비) 옛이름으로 옮김", "LB000388": "미확보 기계산업기사 — 초급숙련기술자(기계·설비) 옛이름으로 옮김", "MO000055": {"원문번호": "주택용저압:기본_200이하", "파일": "재료_유가전력.json"}, "MO000056": {"원문번호": "주택용저압:기본_201~400", "파일": "재료_유가전력.json"}, "MO000057": {"원문번호": "주택용저압:기본_400초과", "파일": "재료_유가전력.json"}, "MO000059": {"원문번호": "주택용저압:전력량_다음200", "파일": "재료_유가전력.json"}, "MO000060": {"원문번호": "주택용저압:전력량_400초과", "파일": "재료_유가전력.json"}, "MO000061": {"원문번호": "주택용저압:월간최저요금", "파일": "재료_유가전력.json"}, "MO000062": {"원문번호": "일반용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000063": {"원문번호": "일반용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000064": {"원문번호": "일반용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000065": {"원문번호": "일반용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000066": {"원문번호": "산업용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000067": {"원문번호": "산업용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000068": {"원문번호": "산업용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000069": {"원문번호": "산업용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000070": {"원문번호": "임시전력갑:기본요금적용", "파일": "재료_유가전력.json"}, "MO000071": {"원문번호": "임시전력갑:전력량요금적용", "파일": "재료_유가전력.json"}, "MO000072": {"원문번호": "임시전력갑:월간최저요금", "파일": "재료_유가전력.json"}}, "키": { "CC000001": {"원문번호": "공통 1-3-1 콘크리트 및 포장용 재료", "파일": "계수_건설품셈_01장_적용기준.json"}, @@ -36471,6 +36471,7 @@ "RT000016": {"원문번호": "rate_performance_guarantee_fee", "파일": "요율_조달청제비율.json"}, "RT000017": {"원문번호": "rate_overhead", "파일": "요율_조달청제비율.json"}, "RT000018": {"원문번호": "rate_profit", "파일": "요율_조달청제비율.json"}, - "RT000019": {"원문번호": "rate_vat", "파일": "요율_조달청제비율.json"} + "RT000019": {"원문번호": "rate_vat", "파일": "요율_조달청제비율.json"}, + "UA000001": {"원문번호": "", "파일": "일위대가조합.json"} } } diff --git a/resources/master_data/일위대가조합.json b/resources/master_data/일위대가조합.json index 0e82e802..052c040c 100644 --- a/resources/master_data/일위대가조합.json +++ b/resources/master_data/일위대가조합.json @@ -2,5 +2,17 @@ "그룹": "일위대가조합", "판": "2026", "비고": ["조합 = 단가산출 로직을 골라 담은 묶음 · 수량·비율·계산은 두지 않음(프로젝트 몫)"], - "줄": [] + "줄": [ + { + "키": "UA000001", + "이름": "견본 조합(지우셔도 됨)", + "구분": "산림품셈", + "상세구분": null, + "단위": "식", + "담은로직": [{ "로직": "GF000219", "메모": "메쌓기 — 돌쌓기 인력" }, { "로직": "GF000119", "메모": "노체포설 — 굴착기 0.7㎥" }, { "로직": "GF000137", "메모": "운반 — 중기운반 트레일러(20ton)" }], + "출처": "자체", + "소유": "현장", + "비고": "브레인이 넣은 견본 · 조합 화면을 바로 볼 수 있게 함 · 지워도 됨" + } + ] } From 3c6e68231c27def509986ae8f43dc90e69ba25c7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 22 Sep 2026 02:17:49 +0900 Subject: [PATCH 3/9] =?UTF-8?q?perf(M01):=20=EB=A1=9C=EC=A7=81=C2=B7?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=EB=AA=A9=EB=A1=9D=EC=9D=B4=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=EB=A5=BC=20=ED=95=9C=20=EB=B2=88=EB=A7=8C=20?= =?UTF-8?q?=EC=9D=BD=EA=B3=A0=20=EB=A7=89=ED=9E=98=EC=9D=80=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=20=EC=84=B8=EC=96=B4=20=EB=91=A0=20(=EC=9D=BC?= =?UTF-8?q?=EA=B0=90=2032)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `_Store_Cache` 신설 — 자국(시각·크기)이 바뀐 파일만 다시 읽음 · 목록은 검사를 돌리지 않고 세어 둔 막힘을 씀 - 요소·표가 바뀌면 모든 로직을, 로직 파일이 바뀌면 그 파일과 그 로직을 부르던 줄만 다시 셈 - 저장 뒤에는 고친 파일을 바로 다시 읽게 알려 줌 — 저장 직후 목록 2.5초 → 0.06초 - `/logics` 둘째 번부터 2.5초 → 0.02초 · `/combos` 0.3초 → 0.01초 · 켠 뒤 첫 번만 3.4초 - 시험 `test_m01_cache.py` 다섯 — 세어 둔 막힘이 줄마다 검사한 값과 같음 · 거름 그대로 · 저장 직후 비침 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj --- M01_MasterData/M01_MasterData_Store.py | 29 +++-- M01_MasterData/M01_MasterData_Store_Cache.py | 114 +++++++++++++++++++ M01_MasterData/M01_MasterData_Store_Combo.py | 7 +- M01_MasterData/M01_MasterData_Store_Make.py | 4 +- resources/master_data/ref/_검증_화면늦음.md | 18 +++ resources/tester/test_m01_cache.py | 112 ++++++++++++++++++ 6 files changed, 270 insertions(+), 14 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_Store_Cache.py create mode 100644 resources/tester/test_m01_cache.py diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 58fddb8a..01c7ffb2 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -24,6 +24,7 @@ import master_copy as mcp # noqa: E402,F401 (`_Store_Make` 가 `store.mcp` 로 import master_keys as mk # noqa: E402 import master_text as mtx # noqa: E402 +from M01_MasterData import M01_MasterData_Store_Cache as cache # noqa: E402 from M01_MasterData.M01_MasterData_Store_Shape import dump, keep_shape # noqa: E402 FOLDER: Path = cm.MASTER # 시험은 사본 폴더로 바꿈 @@ -56,6 +57,17 @@ def read(file: str) -> tuple[dict, str]: return json.loads(raw, parse_float=Decimal, parse_int=Decimal), version_of(raw) +# ── 한 번 읽어 두기(`_Store_Cache`) ──────────────────────────────────── +def loaded() -> tuple[dict[str, dict], object]: + """(파일 묶음, 색인) — **읽기 전용**. 고칠 것이면 `cm.load` 로 새로 읽을 것.""" + return cache.loaded(FOLDER) + + +def blocks() -> dict[str, list[str]]: + """로직 키마다 막힘 까닭 — 미리 세어 둔 값(목록에서 검사를 돌리지 않음).""" + return cache.blocks(FOLDER) + + def items_key(data: dict) -> str: return "표" if data.get("그룹") in cm.mf.TABLE_GROUPS else "줄" @@ -318,9 +330,10 @@ def _logic_files(files: dict[str, dict]): def logics(sub: str, detail: str, q: str, blocked: int | None, owner: str = "") -> list[dict]: - """로직 목록 — 구분 · 상세구분 · 소유 · 찾기로 거름(장 차례대로 · 파일을 가로지름).""" - files = cm.load(folder=FOLDER) - whole = cm.mf.Master(files) + """로직 목록 — 구분 · 상세구분 · 소유 · 찾기로 거름(장 차례대로 · 파일을 가로지름). + 막힘은 미리 세어 둔 값(`blocks`) — 목록에서 줄마다 검사를 돌리지 않음.""" + files, _ = loaded() + why_of = blocks() out = [] for name, data in sorted(_logic_files(files), key=lambda f: f[1].get("차례") or 0): for row in data.get("줄") or []: @@ -330,7 +343,7 @@ def logics(sub: str, detail: str, q: str, blocked: int | None, owner: str = "") continue if not _hit(row, q): continue - reasons = cm.mf.check_logic(whole, row)[0] + reasons = why_of.get(str(row.get("키")), []) if blocked is not None and bool(reasons) != bool(blocked): continue out.append( @@ -358,11 +371,10 @@ def logics(sub: str, detail: str, q: str, blocked: int | None, owner: str = "") def logic(key: str) -> dict: """로직 하나 — 키는 전체에서 하나.""" - files = cm.load(folder=FOLDER) + files, whole = loaded() for name, data in _logic_files(files): for row in data.get("줄") or []: if str(row.get("키")) == key: - whole = cm.mf.Master(files) reasons = cm.mf.check_logic(whole, row)[0] _, version = read(name) return { @@ -427,7 +439,7 @@ def elements(group: str, q: str, limit: int) -> dict: if group not in cm.mf.GROUPS: raise StoreError(404, f"없는 그룹 「{group}」") hits = [] - for name, data in cm.load(folder=FOLDER).items(): + for name, data in loaded()[0].items(): if data.get("그룹") != group: continue for row in data.get(items_key(data)) or []: @@ -491,7 +503,7 @@ def materials(sub: str, detail: str, spec: str, region: str, unit: str, limit: i """재료 고르기 조건 안 후보 목록 — 화면·테스트 컨테이너가 같이 씀. `unit` = 호표 줄 단위 — 주면 그 단위 줄만(단위가 다르면 다른 물건). `기본` = 시험 계산이 쓸 줄(대표 줄이 없을 때의 첫 줄) · 그 줄을 `items` 맨 앞에 둠.""" - whole = cm.master(folder=FOLDER) + whole = loaded()[1] cond = {"구분": sub, "상세구분": detail or None, "규격": spec or None} env = {cm.mf.REGION: region} if region else {} keys = cm.mf.candidates(whole, cond, env, unit or None) @@ -664,4 +676,5 @@ def save(batch: list[dict]) -> list[dict]: tmp.write_text(text, encoding="utf-8", newline="\n") os.replace(tmp, FOLDER / name) out.append({"file": name, "version": version_of(text.encode("utf-8"))}) + cache.stale(dict.fromkeys(names)) # 저장 뒤에는 그 파일을 바로 다시 읽게 return out diff --git a/M01_MasterData/M01_MasterData_Store_Cache.py b/M01_MasterData/M01_MasterData_Store_Cache.py new file mode 100644 index 00000000..2706c0aa --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_Cache.py @@ -0,0 +1,114 @@ +"""M01 마스터 한 번 읽어 두기 — 목록 길이 요청마다 마스터를 새로 읽지 않게. + +`M01_MasterData_Store.loaded` · `.blocks` 가 씀(읽기 전용 길만). +자국(파일 시각·크기)이 바뀐 파일만 다시 읽고, 로직 막힘도 미리 세어 둠 — +목록은 검사를 돌리지 않고 세어 둔 값을 씀. 고치는 길(`calc` · `text` · `save`)은 +사본을 만지므로 그대로 `cm.load` 로 새로 읽음. +""" + +from __future__ import annotations + +import json +import sys +import threading +from decimal import Decimal +from pathlib import Path + +SCRIPTS = Path(__file__).resolve().parents[1] / "resources/master_data/scripts" +sys.path.insert(0, str(SCRIPTS)) + +import check_master as cm # noqa: E402 + +_FRESH = threading.RLock() +_READ: dict[str, tuple[tuple, dict]] = {} # 파일 이름 → (자국, 읽은 내용) +_INDEX: dict = {} # 폴더 · 자국 · files · whole +_BLOCK: dict[str, tuple[tuple, dict]] = {} # 로직 파일 → (자국, {키: (막힘 까닭, 부르는 로직)}) +_BASE: dict[str, tuple] = {} # 막힘을 셀 때 바탕이던, 로직 아닌 파일의 자국 + + +def _stamps(folder: Path) -> dict[str, tuple]: + """첫 층 JSON 자국 — {이름: (시각, 크기)} · 밑줄 파일은 `cm.load` 가 안 읽으므로 뺌.""" + out = {} + for path in sorted(folder.glob("*.json")): + if not path.name.startswith("_"): + stat = path.stat() + out[path.name] = (stat.st_mtime_ns, stat.st_size) + return out + + +def _logic_files(files: dict[str, dict]): + for name, data in files.items(): + if data.get("그룹") == "로직": + yield name, data + + +def forget() -> None: + """읽어 둔 것을 버림 — 파일을 고친 자리(저장)에서 부름.""" + with _FRESH: + _READ.clear() + _INDEX.clear() + _BLOCK.clear() + _BASE.clear() + + +def stale(names) -> None: + """그 파일만 다시 읽게 함 — 저장처럼 스스로 고친 자리에서 부름 + (자국은 시각이 같은 틱이면 못 가릴 수 있으므로 고친 쪽이 알려 줌).""" + with _FRESH: + for name in names: + was = _READ.pop(name, (None, {}))[1] + _BLOCK.pop(name, None) + if was.get("그룹") != "로직": + _BASE.clear() # 요소·표가 바뀌면 모든 로직을 다시 셈 + _INDEX.pop("자국", None) + + +def loaded(folder: Path) -> tuple[dict[str, dict], object]: + """(파일 묶음, 색인) — **읽기 전용**. 고칠 것이면 `cm.load` 로 새로 읽을 것.""" + with _FRESH: + if _INDEX.get("폴더") != folder: # 시험은 사본 폴더로 바꿈 + forget() + _INDEX["폴더"] = folder + now = _stamps(folder) + for gone in [name for name in _READ if name not in now]: + del _READ[gone] + for name, stamp in now.items(): + if _READ.get(name, (None,))[0] != stamp: + raw = (folder / name).read_bytes() + _READ[name] = (stamp, json.loads(raw, parse_float=Decimal, parse_int=Decimal)) + if _INDEX.get("자국") != now: + files = {name: _READ[name][1] for name in now} + _INDEX.update(자국=now, files=files, whole=cm.mf.Master(files)) + return _INDEX["files"], _INDEX["whole"] + + +def blocks(folder: Path) -> dict[str, list[str]]: + """로직 키마다 막힘 까닭 — 미리 세어 둔 값. 요소 파일이 바뀌면 모든 로직을, + 로직 파일은 바뀐 그 파일과 그 로직을 부르던 줄만 다시 셈.""" + with _FRESH: + files, whole = loaded(folder) + stamps = _INDEX["자국"] + base = {n: s for n, s in stamps.items() if files[n].get("그룹") != "로직"} + if _BASE != base: # 인력 · 재료 · 표가 바뀌면 바탕이 달라짐 + _BLOCK.clear() + _BASE.clear() + _BASE.update(base) + moved, fresh = set(), set() # 바뀐 로직 파일의 키(옛것 · 새것) + names = {name for name, _ in _logic_files(files)} + for gone in [name for name in _BLOCK if name not in names]: # 파일째 사라진 것 + moved |= set(_BLOCK.pop(gone)[1]) + for name, data in _logic_files(files): + if _BLOCK.get(name, (None,))[0] == stamps[name]: + continue + got = {str(r.get("키")): cm.mf.check_logic(whole, r) for r in data.get("줄") or []} + moved |= set(_BLOCK.get(name, (None, {}))[1]) | set(got) + _BLOCK[name] = (stamps[name], got) + fresh.add(name) + for name, data in _logic_files(files): # 다른 파일에서 그 로직을 부르던 줄 + if name in fresh: + continue + got = _BLOCK[name][1] + rows = {str(r.get("키")): r for r in data.get("줄") or []} + 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()} diff --git a/M01_MasterData/M01_MasterData_Store_Combo.py b/M01_MasterData/M01_MasterData_Store_Combo.py index e8ae61bb..c363639f 100644 --- a/M01_MasterData/M01_MasterData_Store_Combo.py +++ b/M01_MasterData/M01_MasterData_Store_Combo.py @@ -103,8 +103,7 @@ def _brief(whole, row: dict) -> list[dict]: def combos(sub: str = "", detail: str = "", q: str = "", owner: str = "") -> list[dict]: """조합 목록 — 구분 · 상세구분 · 소유 · 찾기로 거름.""" - files = cm.load(folder=store.FOLDER) - whole = cm.mf.Master(files) + files, whole = store.loaded() out = [] for _, row in mcb.rows(files): if (sub and row.get("구분") != sub) or (detail and row.get("상세구분") != detail): @@ -130,7 +129,7 @@ def combo(key: str) -> dict: """조합 하나 — 담은 로직에 이름·단위를 붙여 줌.""" data, version = _read() row = _find(data, key) - whole = cm.mf.Master(cm.load(folder=store.FOLDER)) + whole = store.loaded()[1] problems = mcb.check_row(whole, row) return { "file": FILE, @@ -144,7 +143,7 @@ def combo(key: str) -> dict: def combos_of_logic(key: str) -> dict: """「이 로직을 쓰는 조합」 — 로직 화면이 거꾸로 볼 때(소요량 「용도」 와 같은 결).""" - files = cm.load(folder=store.FOLDER) + files, _ = store.loaded() return {"key": key, "combos": mcb.using(files, key)} diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index 7fdf6ebe..76dc319d 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -70,7 +70,7 @@ def _all_sections(book: str) -> list[dict]: def _counts() -> dict[tuple[str, str], list[int]]: """(구분, 절 번호) → [표 수, 로직 수] — 아래 절까지 세도록 절 번호의 모든 앞마디에 얹음.""" out: dict[tuple[str, str], list[int]] = {} - for data in cm.load(folder=store.FOLDER).values(): + for data in store.loaded()[0].values(): group = data.get("그룹") at = 0 if group in cm.mf.TABLE_GROUPS else 1 if group == "로직" else None if at is None: @@ -236,7 +236,7 @@ def logic_edit(key: str, version: str, logic: dict, owner: str = "") -> dict: def logic_formula(key: str) -> dict: """로직 한 줄을 별칭 꼴 식 묶음으로 펴 보임 — 되돌릴 원문은 `별칭` 에 그대로(정본은 안 바뀜).""" got = store.logic(key) - master = cm.mf.Master(cm.load(folder=store.FOLDER)) + master = store.loaded()[1] return { "file": got["file"], "version": got["version"], diff --git a/resources/master_data/ref/_검증_화면늦음.md b/resources/master_data/ref/_검증_화면늦음.md index 799f6a9e..39f4004e 100644 --- a/resources/master_data/ref/_검증_화면늦음.md +++ b/resources/master_data/ref/_검증_화면늦음.md @@ -29,3 +29,21 @@ 3. 「로직 개선 시험」 은 같은 길을 그대로 쓰므로 1 · 2 를 고치면 같이 빨라짐. 시험 흔적 없음(어떤 파일도 안 고침 · 프론트 개발서버 재시작 1회는 화면이 빌드 오류로 안 뜨던 것을 되살리려 한 것 — 조합 화면 파일이 다른 창에서 편집 중이던 자국으로 보임). + +## 고친 뒤 (일감 32) + +잰 자리 = 이 창(랩탑 sub_laptop_1) · 임시 사본 폴더 · `TestClient` 로 같은 길을 부름. 데스크탑은 같은 길이 2배쯤 느렸음(위 표 6.0초 = 여기 2.5초). + +| 길 | 앞 | 뒤 | +|---|---|---| +| `/api/m01/logics` 켠 뒤 첫 번 | 2.5초 | 3.4초(마스터 한 번 읽기 + 1,354줄 한 번 세기 · **딱 한 번만**) | +| `/api/m01/logics` 그 다음부터 | 2.5초(매번) | **0.02초** | +| `/api/m01/combos` | 0.3초(데스크탑 1.6초) | **0.01초** | +| 로직 저장 직후 목록 | 2.5초 | **0.06초** | + +- 요청마다 마스터를 새로 읽던 것을 한 번 읽어 두고, 파일 자국(시각·크기)이 바뀐 파일만 다시 읽음(`M01_MasterData_Store_Cache`). +- 목록은 검사를 돌리지 않음 — 로직 막힘을 미리 세어 두고 씀. 요소·표가 바뀌면 모든 로직을, 로직 파일이 바뀌면 그 파일과 그 로직을 부르던 줄만 다시 셈. +- 저장한 쪽이 고친 파일 이름을 알려 주므로 저장 직후 목록에 바로 비침(같은 시각 틱이어도 안 놓침). +- 막힘 거름(`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. diff --git a/resources/tester/test_m01_cache.py b/resources/tester/test_m01_cache.py new file mode 100644 index 00000000..7d614638 --- /dev/null +++ b/resources/tester/test_m01_cache.py @@ -0,0 +1,112 @@ +"""M01 목록이 마스터를 한 번만 읽는지 — `M01_MasterData_Store_Cache`. + +목록(`/logics` · `/combos`)은 요청마다 마스터를 새로 읽지 않고 막힘도 미리 세어 둔 값을 씀. +여기서 보는 것 = ① 세어 둔 값이 줄마다 검사한 것과 같음 ② 파일이 바뀌면 바로 다시 읽음 +③ 저장 직후 목록에 바로 비침 ④ 다른 파일의 로직이 바뀌면 그것을 부르던 줄도 다시 셈. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as store + +REAL = store.FOLDER +CALLER = "GC000994" # 로직_건설품셈_01장_공통.json — 아래 로직을 부름 +CALLEE = "GC000276" # 로직_건설품셈_08장_건설기계.json — 다른 파일에 있음 + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + for path in REAL.glob("*.json"): + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(store, "FOLDER", tmp_path) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _logics(client: TestClient, **params) -> list[dict]: + res = client.get("/api/m01/logics", params=params) + assert res.status_code == 200, res.text + return res.json()["logics"] + + +def _write(file: str, change) -> None: + """정본 파일을 손으로 고침(저장 길을 거치지 않음) — 바뀐 것을 알아채는지 보려고.""" + path = store.FOLDER / file + data = json.loads(path.read_text(encoding="utf-8")) + change(data) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8", newline="\n") + + +def test_세어_둔_막힘이_줄마다_검사한_것과_같다(client: TestClient) -> None: + listed = _logics(client) + files, whole = store.loaded() + rows = { + str(r.get("키")): r for _, d in files.items() if d.get("그룹") == "로직" for r in d["줄"] + } + assert len(listed) == len(rows) + for one in listed: + reasons = store.cm.mf.check_logic(whole, rows[one["키"]])[0] + assert one["reasons"] == reasons and one["blocked"] is bool(reasons) + + +def test_거름이_그대로다(client: TestClient) -> None: + whole = _logics(client) + sub, detail = whole[0]["구분"], whole[0]["상세구분"] + by_sub = _logics(client, sub=sub, detail=detail) + assert by_sub == [x for x in whole if (x["구분"], x["상세구분"]) == (sub, detail)] + assert _logics(client, blocked=1) == [x for x in whole if x["blocked"]] + assert _logics(client, blocked=0) == [x for x in whole if not x["blocked"]] + assert _logics(client, q=CALLER) == [x for x in whole if x["키"] == CALLER] + + +def test_안_바뀌면_다시_읽지_않고_바뀌면_바로_읽는다(client: TestClient) -> None: + files, whole = store.loaded() + assert store.loaded() == (files, whole) # 같은 것을 그대로 씀 + _write("로직_건설품셈_01장_공통.json", lambda d: d["줄"][0].update(비고="자국 바뀜")) + fresh, _ = store.loaded() + assert fresh is not files + assert fresh["로직_건설품셈_01장_공통.json"]["줄"][0]["비고"] == "자국 바뀜" + + +def test_로직을_저장하면_목록에_바로_비친다(client: TestClient) -> None: + one = client.get("/api/m01/logic", params={"key": CALLER}).json() + assert [x for x in _logics(client, q=CALLER)][0]["이름"] == one["logic"]["이름"] + row = {**one["logic"], "이름": "저장 뒤 이름"} + res = client.post( + "/api/m01/save", + json={ + "files": [ + { + "file": one["file"], + "version": one["version"], + "changes": [{"op": "edit", "key": CALLER, "row": row}], + } + ] + }, + ) + assert res.status_code == 200, res.text + listed = [x for x in _logics(client) if x["키"] == CALLER] + assert listed and listed[0]["이름"] == "저장 뒤 이름" + + +def test_부르는_로직이_바뀌면_다른_파일의_줄도_다시_센다(client: TestClient) -> None: + assert [x for x in _logics(client) if x["키"] == CALLER][0]["blocked"] is False + + def add_input(data: dict) -> None: + row = next(r for r in data["줄"] if r["키"] == CALLEE) + row["입력"] = [*row.get("입력", []), {"이름": "새입력", "종류": "수"}] + + _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"]) From d0d287c4be1ca144e5ffc1ad11100020d61acddd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 22 Sep 2026 02:30:38 +0900 Subject: [PATCH 4/9] =?UTF-8?q?=EF=BB=BFtest(M01):=20=EC=A1=B0=ED=95=A9=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=EC=9D=B4=20=EC=A0=95=EB=B3=B8=20=EA=B2=AC?= =?UTF-8?q?=EB=B3=B8=20=EC=A1=B0=ED=95=A9=EC=97=90=20=EC=95=88=20=EA=B9=A8?= =?UTF-8?q?=EC=A7=80=EA=B2=8C=20=E2=80=94=20=EC=82=AC=EB=B3=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EC=A4=84=EC=9D=84=20=EB=B9=84=EC=9B=8C=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20(=EC=9D=BC=EA=B0=90=2030)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8 --- resources/tester/test_m01_combo.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/resources/tester/test_m01_combo.py b/resources/tester/test_m01_combo.py index dbca8ef9..36349715 100644 --- a/resources/tester/test_m01_combo.py +++ b/resources/tester/test_m01_combo.py @@ -5,11 +5,12 @@ - 조합은 이름과 담은 로직 목록만 지님 — 수량·비율·계산 칸을 두지 않음. - 미리 보기 합계 = 담은 로직 저마다의 시험 계산을 비목별로 더한 값(저장 없음). - 검사 — 없는 로직 키 · 같은 로직 두 번 · 빈 조합 · 조합이 조합을 담음. - - 견본 조합은 시험에서만 만들고 정본 파일에는 넣지 않음(정본은 빈 채로 둠). + - 시험은 임시 사본에서 돌고, 사본의 조합 줄은 비워 시작 — 정본에 견본 조합이 있어도 없어도 같음. """ from __future__ import annotations +import json import shutil import sys from decimal import Decimal @@ -38,6 +39,10 @@ def client(tmp_path: Path, monkeypatch) -> TestClient: for path in REAL.glob("*.json"): if not path.name.startswith("_") or path.name == store.mk.BOOK.name: shutil.copy(path, tmp_path / path.name) + own = tmp_path / mcb.FILE # 정본의 견본 조합이 시험 줄 수에 끼지 않게 사본은 비워 시작 + data = json.loads(own.read_text(encoding="utf-8")) + data["줄"] = [] + own.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") monkeypatch.setattr(store, "FOLDER", tmp_path) app = FastAPI() app.include_router(router_module.router) @@ -80,10 +85,11 @@ def _make(client: TestClient, keys=SAMPLE, 이름="돌쌓기 한 벌", owner: st # ── 정본 파일 ───────────────────────────────────────────────────────── -def test_정본_조합_파일은_빈_채로_있다() -> None: - """줄은 사용자가 화면에서 만듦 — 견본은 시험에서만.""" +def test_정본_조합_파일은_틀대로다() -> None: + """줄 수는 못 박지 않음 — 견본이 있어도 없어도 됨 · 줄은 조합 키(UA)와 열 한 벌만.""" data = store.cm.load([mcb.FILE], folder=REAL)[mcb.FILE] - assert data["그룹"] == mcb.GROUP and data["줄"] == [] + assert data["그룹"] == mcb.GROUP + assert all(row["키"].startswith("UA") and set(row) <= set(mcb.COLS) for row in data["줄"]) assert "원문" not in data # 관리자가 만드는 것이라 원문 근거가 없음 From 6767b00137dff4692b62ffaad2ed18d08305741a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 22 Sep 2026 02:34:36 +0900 Subject: [PATCH 5/9] =?UTF-8?q?knowledge(M01):=20=EB=B0=A4=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EC=9A=94=EC=95=BD=20=EA=B0=B1=EC=8B=A0=20=E2=80=94?= =?UTF-8?q?=20=EC=83=88=EB=B2=BD=20=ED=99=94=EB=A9=B4=20=EB=B3=80=ED=99=94?= =?UTF-8?q?=20=C2=B7=20=EB=B9=A8=EB=9D=BC=EC=A7=84=20=EC=88=98=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20100=20=ED=86=B5=EA=B3=BC=20=C2=B7=20?= =?UTF-8?q?=EA=B3=84=EC=88=98=20=ED=91=9C=20=EC=85=8B=20=C2=B7=20=EC=A0=95?= =?UTF-8?q?=ED=95=A0=20=EA=B2=83=20=EB=91=98=20(=EC=9D=BC=EA=B0=90=2031)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8 --- resources/master_data/ref/_밤작업_요약.md | 41 ++++++++++++++++++----- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/resources/master_data/ref/_밤작업_요약.md b/resources/master_data/ref/_밤작업_요약.md index af1912c2..13e93128 100644 --- a/resources/master_data/ref/_밤작업_요약.md +++ b/resources/master_data/ref/_밤작업_요약.md @@ -1,4 +1,4 @@ -# 밤 작업 요약 (2026-09-21 저녁 ~ 09-22 새벽) +# 밤 작업 요약 (2026-09-21 저녁 ~ 09-22 아침) 여섯 창이 한 일 · 사용자가 아침에 읽는 용도. 값·코드는 이 문서에서 안 고침. 근거 = `ref/` 검증 문서 이름. @@ -10,13 +10,31 @@ - 알약 모달 — 호표 줄을 누르면 뜨는 창. 고른 자재·표 줄을 강조. - 일위대가 조합 컨테이너 — 로직 여러 개를 담고 미리 보기 합계를 봄. 수량·계산은 저장 안 됨. (`_검증_조합.md` · `_검증_조합_화면.md`) - 이름 바뀜 — 자재 이름을 고르기 조건으로 잡는 방식으로 바뀜(변수 참조 20줄 이관). (`_검증_변수참조.md`) +- 재료 펼치면 자재품목 목록이 바로 열림 — 처음 펼칠 때 빈 화면으로 기다리던 것을 고침. +- 좁은 화면에서 넓은 표가 잘리면 안내 한 줄이 뜸. +- 화면 글 다듬음 — 「자재」 → 「재료」 따위로 이름을 맞춤. +- 견본 조합 `UA000001` 이 이미 들어 있음 — 이름에 「견본」 표시 · 로직 셋(GF000219 · GF000119 · GF000137) 담김 · 바로 열어 볼 수 있음. 필요 없으면 지워도 됨. **눌러 볼 차례 (세 줄)** 1. 로직 화면에서 13-4-1 · 12-4 · 12-17-1 열어 컨테이너 넷과 호표 세 묶음을 훑음. 2. 호표 줄을 눌러 알약 모달이 뜨고 줄이 강조되는지 봄. -3. 조합 컨테이너에서 새 조합 → 로직 셋 담기 → 미리 보기 합계 → 저장 → 다시 열기. +3. 조합 컨테이너에서 견본 조합 `UA000001` 을 열어 미리 보기 합계를 봄 → 새 조합 하나 만들어 로직 셋 담기 → 저장 → 다시 열기. -## ② 데이터가 채워진 정도 +## ② 빨라진 것 (`_검증_화면늦음.md` 「고친 뒤」) + +로직 목록을 부르는 길이 가장 느렸음(옛 6초). 마스터를 매번 새로 읽던 것을 한 번 읽어 두고 바뀐 파일만 다시 읽게 고침. + +| 길 | 앞 | 뒤 | +|---|---|---| +| 로직 목록 켠 뒤 첫 번 | 2.5초 | 3.4초(한 번만 · 읽어 두는 값) | +| 로직 목록 그 다음부터 | 2.5초 | **0.02초** | +| 조합 목록 | 0.3초 | **0.01초** | +| 로직 저장 직후 목록 | 2.5초 | **0.06초** | + +(잰 자리 = 랩탑 · 데스크탑은 같은 길이 2배쯤 느려 앞이 6초로 보였음.) +**고치는 중**: 로직 저장 자체(6.7초)와 켠 뒤 첫 목록(3.4초) — sub1 이 손봄. 끝나면 이 표를 다시 갱신. + +## ③ 데이터가 채워진 정도 | 항목 | 전 → 후 | 남은 것 | |---|---|---| @@ -27,19 +45,24 @@ 근거: `_검증_관급_가까운줄.md` · `_남은_옛재료줄.md` · `_없는_재료.md`. -## ③ 검증 수치 (`_마감_수치.md` · `_검증_3장_마감.md`) +## ④ 검증 수치 (`_마감_수치.md` · `_검증_3장_마감.md`) | 항목 | 결과 | |---|---| -| 시험(`test_m01_*` + `test_masterdata_*`) | 190 통과 · 실패 0 | +| 시험(`test_m01_*` + `test_masterdata_*`) | 밤 190 통과 → 새벽 195 통과 · 4 실패(견본 조합 탓) → 조합 시험을 고쳐 조합 + 이웃 여섯 파일 **100 통과 · 실패 0** | | `check_master` 틀 · 로직 · 조합 | 0 · 0 · 0건 | -| `check_master` 본문 | 2건(계수 CC000337 · CC000074 결손) | +| `check_master` 본문 | **0건**(계수 결손 둘 메워짐) | +| 계수 표 새로 올림 | CC000338 발생재 공제율 · CC000339~340 강관배관 부자재율(일반업무용 · 병원) | | 일괄 시험 계산 | 통과 1,291 · 멈춤 63(자료 비어서 멈춤 · 화면 탓 아님) | | 단위 경고 | 14건 | -| 줄 수 | 로직 1,354 · 자재품목 30,592 · 기계 654 · 인력 378 · 품셈재료 363 · 소요량+계수 3,030 · 조합 0 | +| 줄 수 | 로직 1,354 · 자재품목 30,592 · 기계 654 · 인력 378 · 품셈재료 363 · 소요량+계수 3,030 · 조합 1(견본) | -밤새 새로 밝힌 어긋남(고칠 것): 조합 `메모`·`이름` 칸이 글자 아닌 값도 저장됨(`_검증_조합.md`) · 3장 줄 이름 글자 2건(`_검증_3장_마감.md`). +고칠 것으로 남은 어긋남: 조합 `메모`·`이름` 칸이 글자 아닌 값도 받는 점(`_검증_조합.md` · 시험은 이미 마련됨) · 단가 비어 멈춘 로직 63(`_단가_빈로직.md` · 자료가 채워져야 풀림). -## ④ 사용자가 정할 것 +## ⑤ 사용자가 정할 것 PLAN 6장을 봄 — 다시 적지 않음. 관급 쪽 상세는 `_확정거리_관급.md`. + +새로 생긴 것 둘: +- 「단가산출 상세」 대 「호표」 — 화면에서 어느 말을 쓸지 정함. +- 견본 조합 `UA000001` — 계속 둘지 지울지 정함. From ef7dc801984a1048f960190e4976442f3a412bb9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 22 Sep 2026 02:39:33 +0900 Subject: [PATCH 6/9] =?UTF-8?q?perf(M01):=20=EC=A0=80=EC=9E=A5=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=EB=A5=BC=20=EA=B3=A0=EC=B9=9C=20=EC=A4=84=20=EB=91=98?= =?UTF-8?q?=EB=A0=88=EB=A1=9C=20=EC=A2=81=ED=9E=88=EA=B3=A0=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EA=B0=80=20=EB=9C=B0=20=EB=95=8C=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20=EB=AF=B8=EB=A6=AC=20=EC=9D=BD=EC=9D=8C=20?= =?UTF-8?q?(=EC=9D=BC=EA=B0=90=2033)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장(`POST /save`) 6.8초 → 0.8초 — 고친 줄과 그 줄을 부르던 줄만 검사 (안 보는 줄이 부르는 키는 목록이 미리 세어 둔 것을 씀 · 돌고 도는 길은 전체 그림으로) - 로직 줄만 고쳤으면 품셈재료 연결 · 인력 준용 검사(`check_links`)도 건너뜀 - 로직 밖 파일(요소 · 표)이 섞인 저장은 예전처럼 전부 검사 — 전체 검사는 `check_master` 몫 - 켠 뒤 첫 목록 3.4초 → 0.03초 — M01 라우터가 뜰 때 뒤에서 한 번 읽어 둠(`cache.warm`) - 저장 전 검사(`check_saved`)를 M01 쪽(`_Store_Cache`)으로 옮김 — 좁히는 값이 있는 자리 · `check_master` 가 700줄에 닿음 - 시험 `test_m01_cache.py` 에 넷 더함 — 없는 로직 키 · 없는 요소 키 · 돌고 도는 참조 · 다른 파일에서 그 줄을 부르던 줄이 깨짐(모두 422) · 미리 읽은 뒤 첫 목록 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj --- M01_MasterData/M01_MasterData_Router.py | 4 + M01_MasterData/M01_MasterData_Store.py | 11 ++- M01_MasterData/M01_MasterData_Store_Cache.py | 53 ++++++++++++ resources/master_data/ref/_검증_화면늦음.md | 16 ++++ resources/master_data/scripts/check_master.py | 26 +++--- resources/tester/test_m01_cache.py | 81 ++++++++++++++++++- 6 files changed, 174 insertions(+), 17 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 2c212d81..191ed674 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -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 diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 01c7ffb2..0bef24c9 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -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: diff --git a/M01_MasterData/M01_MasterData_Store_Cache.py b/M01_MasterData/M01_MasterData_Store_Cache.py index 2706c0aa..a2fa27a3 100644 --- a/M01_MasterData/M01_MasterData_Store_Cache.py +++ b/M01_MasterData/M01_MasterData_Store_Cache.py @@ -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 diff --git a/resources/master_data/ref/_검증_화면늦음.md b/resources/master_data/ref/_검증_화면늦음.md index 39f4004e..a93f925b 100644 --- a/resources/master_data/ref/_검증_화면늦음.md +++ b/resources/master_data/ref/_검증_화면늦음.md @@ -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초**(인력 한 줄 실측): 요소를 가리키는 로직을 키 글자로만 찾으면 원문번호로 가리키는 자리를 놓쳐 저장 검사에 구멍이 나므로 좁히지 않음 — 좁히려면 「요소를 부르는 로직」 지도부터 만들어야 함. diff --git a/resources/master_data/scripts/check_master.py b/resources/master_data/scripts/check_master.py index 6daf320f..0b74792b 100644 --- a/resources/master_data/scripts/check_master.py +++ b/resources/master_data/scripts/check_master.py @@ -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]: diff --git a/resources/tester/test_m01_cache.py b/resources/tester/test_m01_cache.py index 7d614638..b28139b7 100644 --- a/resources/tester/test_m01_cache.py +++ b/resources/tester/test_m01_cache.py @@ -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초 From ff979104eeb579ae3e3a513e1d13ae930f482d6e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 22 Sep 2026 02:53:34 +0900 Subject: [PATCH 7/9] =?UTF-8?q?knowledge(M01):=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EA=B5=90=EC=B0=A8=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=E2=80=94=201,354=20=EC=A0=84=EC=88=98=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=C2=B7=20?= =?UTF-8?q?=EC=A1=B0=ED=95=A9,=20=EC=96=B4=EA=B8=8B=EB=82=A8=200=20(?= =?UTF-8?q?=EC=9D=BC=EA=B0=90=2032)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8 --- resources/master_data/ref/_검증_목록캐시.md | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 resources/master_data/ref/_검증_목록캐시.md diff --git a/resources/master_data/ref/_검증_목록캐시.md b/resources/master_data/ref/_검증_목록캐시.md new file mode 100644 index 00000000..27d0e480 --- /dev/null +++ b/resources/master_data/ref/_검증_목록캐시.md @@ -0,0 +1,24 @@ +# 목록 캐시 교차 검증 (일감 32) + +대상 = `M01_MasterData_Store_Cache` — 마스터를 한 번 읽어 두고 목록은 미리 센 막힘 값을 씀. 정본은 안 건드리고 임시 사본에서만 시험. 잰 뒤 정본 조합 파일·키 대장 해시가 앞뒤 같음. + +## 어긋난 것 — 없음 + +| 확인 | 범위 | 결과 | +|---|---|---| +| ① 미리 센 막힘 = 그 자리에서 검사한 값 | 로직 1,354 전수 | 어긋남 0 (막힘 0건 · 까닭 글까지 같음) | +| ③ 목록 이름·구분·상세구분·결과단위·원문번호·출처·소유·파일 = 파일 값 | 1,354 전수 | 어긋남 0 · 빠지거나 남는 키 0 | +| ③ 거름(구분·상세구분 조합 전부 · 막힘 0/1) | 전부 | 어긋남 0 | +| ② 고친 척한 뒤 다시 센 값 = 캐시 없이 전부 새로 센 값 | 아래 | 어긋남 0 · **덜 센 경우 0** | +| ④ 조합 목록 = 조합 줄마다 새로 검사한 값 | 조합 1줄(견본) + 변형 | 어긋남 0 | + +② 시나리오(모두 전수 대조): 부르는 로직에 입력 추가(불리는 로직 41개 중 25개 뽑아) · 로직 줄 삭제 · 이름만 바꿈 · 로직을 다른 파일로 옮김 · 호표에 로직 호출 새로 추가 → 그 피호출 로직을 고침(새 간선도 다시 셈) · 요소·표 파일 8개(계수·소요량·요율) 고침 · 요소 줄 삭제 둘(인력 LB000014 → 로직 31개가 막힘으로 바뀜 · 재료 MP000082 → 1개). +④ 변형: 담은 로직에 입력 추가(막힘 0 유지) · 담은 로직 삭제(막힘 1) · 조합 줄 추가 · 조합 비움. + +## 어긋남은 아니나 알아 둘 것 + +1. **파일 단위로 다시 셈** — 로직 하나를 고치면 그 파일 줄 전부를 다시 셈. 08장 건설기계(462줄)의 로직 5개는 다시 센 수가 462 인데 꼭 필요한 것은 12~138줄. 값은 맞고 덜 세지 않음 — 그저 더 셈(0.1초대 아님·초 단위 아님, 저장 뒤 첫 목록에서만). +2. **같은 크기 · 같은 시각 고침은 못 알아챔** — 파일 자국(시각·크기)이 그대로면 옛 값이 남음(시험: 글자 하나를 같은 크기로 바꾸고 시각을 되돌림 → 목록 이름이 옛것). 저장 길은 `stale` 로 알려 주므로 안 겪음 · 손으로 파일을 고치고 시각까지 되돌리는 경우만 해당. +3. 정본에 막힌 로직이 0 이라 「같은 값」 판정은 막힘이 있는 경우를 고침 시나리오(로직 31개가 막힘으로 바뀜 등)로 보강해 확인. + +시험 코드는 `tmp/` 임시(git 밖) — 다시 하려면 `test_m01_cache.py` 에 시나리오를 얹을 것. From e2bd128d4f0b5f98574aef07b7f9cecc7c532306 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 22 Sep 2026 02:56:21 +0900 Subject: [PATCH 8/9] =?UTF-8?q?knowledge(M01):=20=EB=B0=A4=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EC=9A=94=EC=95=BD=20=EB=A7=88=EB=AC=B4=EB=A6=AC=20?= =?UTF-8?q?=E2=80=94=20=EC=B2=AB=20=EB=AA=A9=EB=A1=9D=200.03=EC=B4=88=20?= =?UTF-8?q?=C2=B7=20=EC=A0=80=EC=9E=A5=200.8=EC=B4=88=20=C2=B7=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EA=B5=90=EC=B0=A8=20=EA=B2=80=EC=A6=9D=20=C2=B7=20?= =?UTF-8?q?=EC=95=84=EC=B9=A8=EC=97=90=20=EB=88=8C=EB=9F=AC=20=EB=B3=BC=20?= =?UTF-8?q?=EC=B0=A8=EB=A1=80=20=EB=8B=A4=EC=84=AF=20=EC=A4=84=20=C2=B7=20?= =?UTF-8?q?PLAN=201-3=20=EC=97=90=20=EC=A0=95=ED=95=A0=20=EA=B2=83=20?= =?UTF-8?q?=EB=91=98=20(=EC=9D=BC=EA=B0=90=2033)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8 --- resources/master_data/ref/_밤작업_요약.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/resources/master_data/ref/_밤작업_요약.md b/resources/master_data/ref/_밤작업_요약.md index 13e93128..a83ad140 100644 --- a/resources/master_data/ref/_밤작업_요약.md +++ b/resources/master_data/ref/_밤작업_요약.md @@ -26,13 +26,15 @@ | 길 | 앞 | 뒤 | |---|---|---| -| 로직 목록 켠 뒤 첫 번 | 2.5초 | 3.4초(한 번만 · 읽어 두는 값) | +| 로직 목록 켠 뒤 첫 번 | 3.2초 | **0.03초**(서버가 뜰 때 미리 읽어 둠) | | 로직 목록 그 다음부터 | 2.5초 | **0.02초** | | 조합 목록 | 0.3초 | **0.01초** | | 로직 저장 직후 목록 | 2.5초 | **0.06초** | +| 로직 한 줄 저장 | 6.8초 | **0.8초** | (잰 자리 = 랩탑 · 데스크탑은 같은 길이 2배쯤 느려 앞이 6초로 보였음.) -**고치는 중**: 로직 저장 자체(6.7초)와 켠 뒤 첫 목록(3.4초) — sub1 이 손봄. 끝나면 이 표를 다시 갱신. +값이 그대로인지 전수로 맞춰 봄(`_검증_목록캐시.md`) — 로직 1,354줄 · 조합 · 고친 척한 경우 모두 어긋남 0. +**남은 것**: 인력·재료 같은 요소 파일 저장은 아직 7초(고치는 중) · 로직 저장의 0.8초 가운데 0.6초는 파일 두 번 읽기. (`_검증_화면늦음.md`) ## ③ 데이터가 채워진 정도 @@ -61,8 +63,16 @@ ## ⑤ 사용자가 정할 것 -PLAN 6장을 봄 — 다시 적지 않음. 관급 쪽 상세는 `_확정거리_관급.md`. +PLAN 6장은 이 창의 PLAN.md 에 없어 **PLAN 1-3 에 적음(이 창 PLAN.md 는 git 밖 파일이라 여기에도 옮겨 둠)** — 다시 적지 않음. 관급 쪽 상세는 `_확정거리_관급.md`. 새로 생긴 것 둘: - 「단가산출 상세」 대 「호표」 — 화면에서 어느 말을 쓸지 정함. - 견본 조합 `UA000001` — 계속 둘지 지울지 정함. + +## ⑥ 아침에 눌러 볼 차례 + +1. 로그인함. +2. 마스터 데이터 화면을 엶. +3. 단가산출 로직에서 13-4-1 을 열어 컨테이너와 호표 세 묶음을 봄. +4. 로직 개선 시험에서 같은 로직을 열어 컨테이너 넷 · 텍스트 수식 두 칸을 보고, 호표 줄을 눌러 알약 모달이 뜨는지 봄. +5. 일위대가 조합에서 견본 조합 `UA000001` 을 열고 미리 보기 합계를 봄. From 3a4823b6da58d87e30ef09e1eaa98ad16e8e6c9b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 22 Sep 2026 02:59:52 +0900 Subject: [PATCH 9/9] =?UTF-8?q?perf(M01):=20=EC=9A=94=EC=86=8C=C2=B7?= =?UTF-8?q?=ED=91=9C=20=EC=A0=80=EC=9E=A5=EB=8F=84=20=ED=9D=94=EB=93=A4?= =?UTF-8?q?=EB=A6=AC=EB=8A=94=20=EB=A1=9C=EC=A7=81=EB=A7=8C=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=E2=80=94=20=E3=80=8C=EC=96=B4=EB=8A=90=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=9D=B4=20=EB=AC=B4=EC=97=87=EC=9D=84=20=EB=B3=B4?= =?UTF-8?q?=EB=8A=94=EC=A7=80=E3=80=8D=20=EC=A7=80=EB=8F=84=20(=EC=9D=BC?= =?UTF-8?q?=EA=B0=90=2033)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인력 한 줄 저장 7.0초 → 0.9초 · 자재품목 7.2초 → 2.0초 · 소요량 표 1.7초 · 자재품목 저장 뒤 목록 2.5초 → 0.45초 · 켠 뒤 첫 목록 3.4초 → 1.6초 - 로직 줄을 셀 때 `Master.get` 을 덧옷(`_Watch`)으로 감싸 그 줄이 본 자리를 모음 — 키 · 「ID:원문번호」 · 없는 키를 물은 것까지 · 재료 고르기 줄은 자재품목 묶음 통째 - 저장은 그 지도로 흔들리는 줄만 검사(고친 키 · 고치기 전후 원문번호 · 지운 줄) · 요소를 고친 저장은 연결·준용 검사를 그대로 돌림 - 저장 뒤 목록은 흔들린 자리를 알려 받아(`stale(names, marks)`) 그 줄만 다시 셈 · 다른 창이 고친 파일이면 예전처럼 통째로 다시 셈 - 자재품목을 「구분」 으로 미리 갈라 둠(`master_material.by_class`) — 고르기 후보를 3만 줄에서 훑지 않음 · 가른 차례는 파일 차례 그대로라 고르는 줄은 안 바뀜 - 전수 확인 — 요소·표 파일 90 개마다 줄을 통째로 비우고 로직 1,354 줄을 다시 검사, 달라진 줄이 모두 지도 안(지도 밖 0) - 시험 `test_m01_cache.py` 에 셋 더함(열둘) — 쓰이는 요소를 지우면 저장이 막힘 · 요소를 고쳐 저장한 뒤 막힘 표시가 줄마다 검사한 것과 같음 · 인력 줄을 비운 전수 대조 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj --- M01_MasterData/M01_MasterData_Store.py | 6 +- M01_MasterData/M01_MasterData_Store_Cache.py | 123 ++++++++++++++---- resources/master_data/ref/_검증_화면늦음.md | 21 +++ resources/master_data/scripts/check_master.py | 2 +- .../master_data/scripts/master_material.py | 17 ++- resources/tester/test_m01_cache.py | 58 +++++++++ 6 files changed, 193 insertions(+), 34 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 0bef24c9..cf8d28e0 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -665,7 +665,8 @@ 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) - # 고친 줄과 그 줄을 부르던 줄만 검사 — 마스터 전체 검사는 `check_master` 몫 + # 저장이 흔드는 줄만 검사(고친 줄 · 그 줄을 부르던 줄 · 그 요소를 보던 줄) — + # 마스터 전체 검사는 `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] @@ -683,5 +684,6 @@ def save(batch: list[dict]) -> list[dict]: tmp.write_text(text, encoding="utf-8", newline="\n") os.replace(tmp, FOLDER / name) out.append({"file": name, "version": version_of(text.encode("utf-8"))}) - cache.stale(dict.fromkeys(names)) # 저장 뒤에는 그 파일을 바로 다시 읽게 + # 저장 뒤에는 그 파일을 바로 다시 읽게 · 흔들린 자리를 알려 줘 그 줄만 다시 세게 + cache.stale(dict.fromkeys(names), narrowed[2]) return out diff --git a/M01_MasterData/M01_MasterData_Store_Cache.py b/M01_MasterData/M01_MasterData_Store_Cache.py index a2fa27a3..c704de76 100644 --- a/M01_MasterData/M01_MasterData_Store_Cache.py +++ b/M01_MasterData/M01_MasterData_Store_Cache.py @@ -4,7 +4,8 @@ 자국(파일 시각·크기)이 바뀐 파일만 다시 읽고, 로직 막힘도 미리 세어 둠 — 목록은 검사를 돌리지 않고 세어 둔 값을 씀. 고치는 길(`calc` · `text` · `save`)은 사본을 만지므로 그대로 `cm.load` 로 새로 읽음. -저장 전 검사(`check_saved`)도 여기 — 세어 둔 부르기 그림으로 고친 줄 둘레만 봄(`narrow`). +저장 전 검사(`check_saved`)도 여기 — 어느 로직이 어느 요소·표·로직을 보는지 세어 두고 +그것으로 저장이 흔드는 줄만 검사함(`narrow`). """ from __future__ import annotations @@ -19,12 +20,14 @@ 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_material as mm # noqa: E402 (자재품목 테이블ID — 재료 고르기는 그 묶음을 훑음) _FRESH = threading.RLock() _READ: dict[str, tuple[tuple, dict]] = {} # 파일 이름 → (자국, 읽은 내용) _INDEX: dict = {} # 폴더 · 자국 · files · whole -_BLOCK: dict[str, tuple[tuple, dict]] = {} # 로직 파일 → (자국, {키: (막힘 까닭, 부르는 로직)}) +_BLOCK: dict[str, tuple[tuple, dict]] = {} # 로직 파일 → (자국, {키: `_check` 가 센 것}) _BASE: dict[str, tuple] = {} # 막힘을 셀 때 바탕이던, 로직 아닌 파일의 자국 +_MARKED: dict[str, set] = {} # 우리가 고친 요소·표 파일 → 흔들린 자리(`narrow` 가 고른 것) def _stamps(folder: Path) -> dict[str, tuple]: @@ -43,6 +46,36 @@ def _logic_files(files: dict[str, dict]): yield name, data +class _Watch: + """`Master.get` 을 감싸 그 로직 줄이 본 요소·표를 적어 둠 — 저장 때 검사를 좁히는 지도.""" + + def __init__(self, whole): + self._whole, self.seen = whole, set() + + def get(self, ref): + self.seen.add(str(ref)) + return self._whole.get(ref) + + def __getattr__(self, name): + return getattr(self._whole, name) + + def __setattr__(self, name, value): # 속에 적어 둠 — 미리 갈라 둔 것을 줄마다 다시 안 짜게 + if name in ("_whole", "seen"): + super().__setattr__(name, value) + else: + setattr(self._whole, name, value) + + +def _check(whole, row: dict) -> tuple[list[str], set, set]: + """(막힘 까닭, 부르는 로직, 본 요소·표) — 본 것은 키 · 「ID:원문번호」 · + 재료 고르기가 있으면 자재품목 묶음 통째(「#MT」 · 후보를 그 안에서 고름).""" + watch = _Watch(whole) + why, calls = cm.mf.check_logic(watch, row) + if any(isinstance(h.get("요소"), dict) for h in row.get("호표") or []): + watch.seen.add("#" + mm.MARKET) + return why, calls, watch.seen + + def forget() -> None: """읽어 둔 것을 버림 — 파일을 고친 자리(저장)에서 부름.""" with _FRESH: @@ -50,17 +83,19 @@ def forget() -> None: _INDEX.clear() _BLOCK.clear() _BASE.clear() + _MARKED.clear() -def stale(names) -> None: +def stale(names, marks=()) -> None: """그 파일만 다시 읽게 함 — 저장처럼 스스로 고친 자리에서 부름 - (자국은 시각이 같은 틱이면 못 가릴 수 있으므로 고친 쪽이 알려 줌).""" + (자국은 시각이 같은 틱이면 못 가릴 수 있으므로 고친 쪽이 알려 줌). + `marks` = 요소·표에서 흔들린 자리 — 그 자리를 보던 로직 줄만 다시 셈.""" with _FRESH: for name in names: was = _READ.pop(name, (None, {}))[1] _BLOCK.pop(name, None) if was.get("그룹") != "로직": - _BASE.clear() # 요소·표가 바뀌면 모든 로직을 다시 셈 + _MARKED.setdefault(name, set()).update(marks) _INDEX.pop("자국", None) @@ -90,10 +125,20 @@ def blocks(folder: Path) -> dict[str, list[str]]: files, whole = loaded(folder) stamps = _INDEX["자국"] base = {n: s for n, s in stamps.items() if files[n].get("그룹") != "로직"} + marks = None # 요소·표에서 흔들린 자리 — None 이면 무엇이 바뀐지 모름(통째로 다시) if _BASE != base: # 인력 · 재료 · 표가 바뀌면 바탕이 달라짐 - _BLOCK.clear() + off = {n for n in set(_BASE) | set(base) if _BASE.get(n) != base.get(n)} + marks = set() + for name in off: + if name not in _MARKED: # 다른 창이 고친 파일 — 어디가 흔들렸는지 모름 + marks = None + break + marks |= _MARKED[name] + if marks is None: + _BLOCK.clear() _BASE.clear() _BASE.update(base) + _MARKED.clear() moved, fresh = set(), set() # 바뀐 로직 파일의 키(옛것 · 새것) names = {name for name, _ in _logic_files(files)} for gone in [name for name in _BLOCK if name not in names]: # 파일째 사라진 것 @@ -101,45 +146,64 @@ def blocks(folder: Path) -> dict[str, list[str]]: for name, data in _logic_files(files): if _BLOCK.get(name, (None,))[0] == stamps[name]: continue - got = {str(r.get("키")): cm.mf.check_logic(whole, r) for r in data.get("줄") or []} + got = {str(r.get("키")): _check(whole, r) for r in data.get("줄") or []} moved |= set(_BLOCK.get(name, (None, {}))[1]) | set(got) _BLOCK[name] = (stamps[name], got) fresh.add(name) - for name, data in _logic_files(files): # 다른 파일에서 그 로직을 부르던 줄 + for name, data in _logic_files(files): # 그 로직을 부르던 줄 · 바뀐 요소를 보던 줄 if name in fresh: continue got = _BLOCK[name][1] rows = {str(r.get("키")): r for r in data.get("줄") or []} - 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()} + shaken = [ + k + for k, (_, calls, seen) in got.items() + if calls & moved or (marks and seen & marks) + ] + for key in shaken: + got[key] = _check(whole, rows[key]) + return {key: why for _, got in _BLOCK.values() for key, (why, *_) in got.items()} -def graph(folder: Path) -> dict[str, set]: - """로직 키마다 부르는 로직 키 — 미리 세어 둔 것(저장 때 검사를 좁히는 데 씀).""" +def counted(folder: Path) -> dict[str, tuple]: + """로직 키마다 (막힘 까닭, 부르는 로직, 본 요소·표) — 미리 세어 둔 것.""" with _FRESH: blocks(folder) - return {key: calls for _, got in _BLOCK.values() for key, (_, calls) in got.items()} + return {key: got for _, rows in _BLOCK.values() for key, got in rows.items()} + + +def _rows_of(data: dict) -> list: + return data.get("표") or data.get("줄") or [] def narrow(folder: Path, batch: list[dict], before: dict, after: dict): - """(검사할 로직 키, 안 보는 줄이 부르는 키) — 저장이 건드린 줄과 그 줄을 부르던 줄만. + """(검사할 로직 키, 안 보는 줄이 부르는 키, 요소도 고쳤는지) — 저장이 흔드는 줄만. - 로직 밖 파일(요소 · 표)이 섞이면 어느 로직이 흔들릴지 모르므로 None — 전부 검사. + 로직 줄은 고친 줄과 **그 줄을 부르던 줄** · 요소·표 줄은 그 줄을 **보던 로직** + (키 · 「ID:원문번호」 · 자재품목은 묶음 통째 — `_Watch` 가 적어 둔 지도). """ - if any(after[part["file"]].get("그룹") != "로직" for part in batch): - return None - calls, touched, gone = graph(folder), set(), set() + counts = counted(folder) + calls = {key: got[1] for key, got in counts.items()} + touched, gone, marks = set(), 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"} + name = part["file"] + was, now = ({str(r.get("키")): r for r in _rows_of(side[name])} for side in (before, after)) + keys = (set(now) - set(was)) | { + str(c.get("key")) for c in part.get("changes") or [] if c.get("op") != "add" + } + if after[name].get("그룹") == "로직": + touched |= keys + gone |= set(was) - set(now) + continue + for key in keys | (set(was) - set(now)): # 지운 줄도 — 가리키던 자리가 끊김 + marks |= {key, "#" + key[:2]} + for side in (was, now): + number = (side.get(key) or {}).get("원문번호") + if number: + marks.add(f"{key[:2]}:{number}") 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} + only |= {key for key, got in counts.items() if got[2] & marks} + return only, {key: called for key, called in calls.items() if key not in gone}, marks def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> list[str]: @@ -150,7 +214,10 @@ def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> l """ 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) + out += cm.check_logics(files, whole, book, narrowed and narrowed[:2]) + if narrowed and narrowed[2]: # 요소·표를 고쳤으면 연결 · 준용도 봄(로직 좁힘과 따로) + out += cm.check_links(whole) + return out + cm.mcb.check_all(files, whole) def warm(folder: Path) -> threading.Thread: diff --git a/resources/master_data/ref/_검증_화면늦음.md b/resources/master_data/ref/_검증_화면늦음.md index a93f925b..9301bdf9 100644 --- a/resources/master_data/ref/_검증_화면늦음.md +++ b/resources/master_data/ref/_검증_화면늦음.md @@ -63,3 +63,24 @@ - 저장 뒤에도 잡히는지 — 견본 넷이 모두 422 로 막힘: 없는 로직 키(GC999999) · 없는 인력 요소 키(LB999999) · 돌고 도는 참조 · 고친 줄을 **다른 파일에서** 부르던 줄이 깨짐(입력 하나 더함). 시험 `resources/tester/test_m01_cache.py` 아홉(넷이 이번 것). - 단위 경고(`check_units`)는 애초부터 저장 검사가 아니라 `check_master 단위` 몫 — 14건 그대로. - 남은 늦은 자리 — 저장 0.8초 가운데 0.6초는 마스터를 두 번(고치기 전·후) 새로 읽는 것. **요소 파일 저장은 그대로 7.0초**(인력 한 줄 실측): 요소를 가리키는 로직을 키 글자로만 찾으면 원문번호로 가리키는 자리를 놓쳐 저장 검사에 구멍이 나므로 좁히지 않음 — 좁히려면 「요소를 부르는 로직」 지도부터 만들어야 함. + +## 고친 뒤 3 (일감 33 — 요소 저장 7초) + +요소·표를 한 줄 고쳐도 로직 1,354 줄을 다 검사하던 자리. 잰 자리는 위와 같음. + +| 길 | 앞 | 뒤 | +|---|---|---| +| 인력 한 줄 저장 | 7.0초 | **0.9초** | +| 자재품목 한 줄 저장 | 7.2초 | **2.0초** | +| 자재품목 저장 뒤 목록 | 2.5초 | **0.45초** | +| 소요량 표 한 줄 저장 | 7초쯤(인력과 같은 길 · 따로 안 쟀음) | **1.7초** | +| 로직 한 줄 저장 | 0.8초 | 0.7초 | +| 켠 뒤 첫 목록 | 3.4초 | **1.6초**(미리 읽기 1.0초 · 뒤에서 하므로 기다리지 않음) | + +- **어느 로직이 무엇을 보는지 지도** — 로직 줄을 셀 때 `Master.get` 을 덧옷(`_Watch`)으로 감싸 그 줄이 본 자리를 모아 둠(키 · 「ID:원문번호」 · 없는 키를 물은 것까지). 재료 고르기가 있는 줄은 자재품목 묶음 통째(「#MT」) — 후보를 그 안에서 고르므로. 지도는 막힘과 같은 자리에 같은 방식으로 갱신됨(파일 자국이 바뀐 것만). +- **저장** — 그 지도로 흔들리는 줄만 검사: 고친 요소의 키 · 원문번호(고치기 전·후 둘 다) · 지운 줄 · 자재품목이면 묶음 통째. 로직 줄 고침은 앞서대로 고친 줄과 그 줄을 부르던 줄. 요소를 고친 저장은 연결·준용 검사(`check_links`)를 그대로 돌림. +- **저장 뒤 목록** — 고친 쪽이 흔들린 자리를 알려 주므로(`stale(names, marks)`) 그 자리를 보던 줄만 다시 셈. 다른 창이 고친 파일이면 무엇이 흔들렸는지 모르므로 예전처럼 통째로 다시 셈. +- **자재품목을 「구분」 으로 미리 갈라 둠**(`master_material.by_class`) — 고르기 후보를 3만 줄에서 훑던 것을 그 구분 안에서만. 저장·목록·`check_master 로직`(2.1초)·`단위`(1.5초)가 함께 빨라짐. 가른 차례는 파일 차례 그대로라 고르는 줄은 바뀌지 않음. +- **전수 확인** — 요소·표 파일 90 개마다 그 파일 줄을 통째로 비운 뒤 로직 1,354 줄을 모두 다시 검사해, 결과가 달라진 줄이 지도가 고른 줄 안에 드는지 봄: **지도 밖 0**(모든 파일에서 「지도 = 달라진 줄」 이 정확히 같음). 지도 한 줄이 보는 자리는 가운데 4 곳 · 가장 많은 170 곳 · 고르기를 쓰는 줄 189. +- 시험 — `resources/tester/test_m01_cache.py` 열둘(셋이 이번 것: 쓰이는 요소를 지우면 저장이 막힘 · 요소를 고쳐 저장한 뒤 막힘 표시가 줄마다 검사한 것과 같음 · 인력 줄을 비운 전수 대조가 지도 안에 듦). +- 남은 늦은 자리 — 자재품목 저장 2.0초의 대부분은 고르기 쓰는 로직 189 줄을 저장 앞뒤로 두 번 검사하는 것 · 마스터를 두 번 새로 읽는 0.6초. diff --git a/resources/master_data/scripts/check_master.py b/resources/master_data/scripts/check_master.py index 0b74792b..6f2193b2 100644 --- a/resources/master_data/scripts/check_master.py +++ b/resources/master_data/scripts/check_master.py @@ -570,7 +570,7 @@ def check_logics( out += [f"{name} · {x}" for x in found] graph[key] = calls out += [f"돌고 도는 참조 · {' → '.join(loop)}" for loop in mf.find_loops(graph)] - return out + duplicate_keys(files, book) + ([] if only else check_links(whole)) + return out + duplicate_keys(files, book) + ([] if only is not None else check_links(whole)) def duplicate_keys(files: dict[str, dict], book: dict | None = None) -> list[str]: diff --git a/resources/master_data/scripts/master_material.py b/resources/master_data/scripts/master_material.py index d88a77cd..8f30ab58 100644 --- a/resources/master_data/scripts/master_material.py +++ b/resources/master_data/scripts/master_material.py @@ -116,6 +116,18 @@ def cond_text(cond: dict) -> str: return "|".join(str(cond.get(k) or "") for k in PICK_KEYS) +def by_class(master: mf.Master) -> dict[str, dict]: + """자재품목을 「구분」 으로 미리 갈라 둠(마스터마다 한 번) — 고르기 후보를 찾을 때 + 3만 줄을 다 훑지 않게. 가른 차례는 파일 차례 그대로.""" + got = getattr(master, "_by_class", None) + if got is None: + got = {} + for key, row in master.index.get(MARKET, {}).items(): + got.setdefault(row.get("구분"), {})[key] = row + master._by_class = got + return got + + def candidates( master: mf.Master, cond: dict, env: dict | None = None, unit: str | None = None ) -> list[str]: @@ -127,9 +139,8 @@ def candidates( need = plain_unit(unit) if unit else "" hits = [ key - for key, row in master.index.get(MARKET, {}).items() - if row.get("구분") == cond.get("구분") - and (not detail or row.get("상세구분") == detail) + for key, row in by_class(master).get(cond.get("구분"), {}).items() + if (not detail or row.get("상세구분") == detail) and all(w in plain_spec(row.get("규격")) for w in want) and (not need or plain_unit(row.get("단위")) == need) ] diff --git a/resources/tester/test_m01_cache.py b/resources/tester/test_m01_cache.py index b28139b7..64f3e6f1 100644 --- a/resources/tester/test_m01_cache.py +++ b/resources/tester/test_m01_cache.py @@ -189,3 +189,61 @@ def test_미리_읽어_두면_첫_목록이_기다리지_않는다(client: TestC start = time.perf_counter() assert _logics(client) assert time.perf_counter() - start < 1.0 # 미리 안 읽으면 2~3초 + + +def _plain(file: str) -> dict: + """정본 파일 그대로(수는 글이 아닌 그대로) — 저장에 실어 보낼 줄을 꺼내려고.""" + return json.loads((store.FOLDER / file).read_text(encoding="utf-8")) + + +def _save_file(client: TestClient, file: str, changes: list[dict]): + """정본 파일을 저장 길로 고침 — 요소·표 줄도 같은 길.""" + return client.post( + "/api/m01/save", + json={"files": [{"file": file, "version": store.read(file)[1], "changes": changes}]}, + ) + + +def test_쓰이는_요소를_지우면_저장이_막힌다(client: TestClient) -> None: + """요소 저장도 좁혀 검사하지만 그 요소를 쓰던 로직은 그대로 걸림.""" + _logics(client) # 미리 세어 둔 값이 선 평소 모양 + res = _save_file(client, "인력.json", [{"op": "delete", "key": "LB000014"}]) + assert any("LB000014" in x for x in _errors(res)) + + +def test_요소를_고쳐_저장해도_막힘_표시가_전수와_같다(client: TestClient) -> None: + """요소를 고친 뒤 다시 센 막힘이 줄마다 검사한 것과 같은지 — 좁혀 세는 길.""" + was = _logics(client) + raw = _plain("인력.json") + row = {**next(r for r in raw["줄"] if str(r.get("키")) == "LB000014"), "비고": "저장 검사 시험"} + res = _save_file(client, "인력.json", [{"op": "edit", "key": "LB000014", "row": row}]) + assert res.status_code == 200, res.text + listed = _logics(client) + files, whole = store.loaded() + rows = { + str(r.get("키")): r for _, d in files.items() if d.get("그룹") == "로직" for r in d["줄"] + } + assert [x["키"] for x in listed] == [x["키"] for x in was] + for one in listed: + assert one["reasons"] == store.cm.mf.check_logic(whole, rows[one["키"]])[0] + + +def test_요소_지도가_전수_검사와_같다(client: TestClient) -> None: + """「어느 로직이 어느 요소를 보는지」 지도가 놓치는 자리가 없는지 — + 인력 줄을 통째로 비우고 1,354 줄을 다 검사해, 달라진 줄이 지도 안에 드는지 봄.""" + counts = store.cache.counted(store.FOLDER) + files, _ = store.loaded() + marks = set() + for row in files["인력.json"]["줄"]: + key = str(row.get("키")) + marks |= {key, "#" + key[:2]} + if row.get("원문번호"): + marks.add(f"{key[:2]}:{row['원문번호']}") + marked = {key for key, got in counts.items() if got[2] & marks} + empty = {**files, "인력.json": {**files["인력.json"], "줄": []}} + other = store.cm.mf.Master(empty) + rows = { + str(r.get("키")): r for _, d in files.items() if d.get("그룹") == "로직" for r in d["줄"] + } + changed = {k for k, r in rows.items() if store.cm.mf.check_logic(other, r)[0] != counts[k][0]} + assert changed and changed <= marked