From 9b92d6718901f4850cb819f3adfb65aa92af09e4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 18:41:43 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(793-3b):=20=EA=B7=BC=EA=B1=B0=20?= =?UTF-8?q?=EC=A0=9C=EC=95=88=20=EC=84=B8=20=EB=8B=A8=EC=9D=84=20793-3a=20?= =?UTF-8?q?=EA=B8=B8=EB=A1=9C=20=EC=9D=B4=EC=9D=8C=20=C2=B7=20=EC=9D=B4=20?= =?UTF-8?q?=EC=A4=84=EC=97=90=20=EC=9E=87=EA=B8=B0=20=EA=B3=A0=EB=A5=B8=20?= =?UTF-8?q?=EA=B2=83=20=3D=20=EC=98=AE=EA=B8=B8=20=EC=B8=B5=20SK=20=C2=B7?= =?UTF-8?q?=20=ED=99=98=EC=82=B0=20=EA=B3=84=EC=88=98=20=EB=84=A3=EC=9D=84?= =?UTF-8?q?=20=EB=95=8C=20=EC=84=9C=EB=B2=84=EA=B0=80=20=EB=8B=A4=EC=8B=9C?= =?UTF-8?q?=20=EB=B0=94=EA=BF=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 가져온 식 만들 때 SK 가져온.근거후보 = 793-3a 블록 제안 근거후보(San_Basis_Store.block · 못 셈 = 빈 칸) - 머리 판 후보 = 원문번호 · 옵션 · 품검산 · 입력필요 n · 까닭 [표지 글] · [우리 기초로 바꾸기] 입력 필요 = 바인딩 + 후보 입력필요 + 환산 - 환산 계수 = 가져온 식에서 to-base 다시(바꿈 전 · 후 서버 정본) · 계수 없을 땐 시트 안 엶(셈 길 400 없음) - 단산 줄 고른 것 층 · 주인 뺌 — 층을 주면 옮기기가 SK 를 못 찾아 고정 줄로 옮겨짐 - 시험 test_793_3b 9(근거후보 채움 · SK 참조 옮기기) Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_Hopyo_Imported.ts | 38 ++++++++++++++----- .../M02_MasterTemplete_Hopyo_ImportedPanel.ts | 12 ++++-- M02_MasterTemplete/M02_Sangeun_FromHopyo.py | 11 +++++- M02_MasterTemplete/M02_Sangeun_Imported.py | 4 +- .../logic/M02_Hopyo_Imported_Model.ts | 23 ++++++----- resources/tester/test_793_3b_import_screen.py | 25 ++++++++++++ 6 files changed, 86 insertions(+), 27 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts index c2e1f3b85..3ed485e82 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts @@ -13,6 +13,7 @@ import { createButton } from "@ui/ui_template_button"; import { el, showToast } from "@ui/ui_template_elements"; +import { createText } from "@ui/ui_template_text"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { askConfirm, openModal } from "@ui/ui_template_modal"; @@ -20,7 +21,7 @@ import { call, readTemplate, saveTemplate } from "./M02_MasterTemplete_Api_Fetch import { openFindPicker } from "./M02_MasterTemplete_Find_Pick"; import { at, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; import { checkJudge, readJudgeBlock } from "./M02_MasterTemplete_Hopyo_JudgeApi"; -import { importedPanel, type PanelState } from "./M02_MasterTemplete_Hopyo_ImportedPanel"; +import { importedPanel, tx, type PanelState } from "./M02_MasterTemplete_Hopyo_ImportedPanel"; import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail"; import { marksOf, @@ -104,6 +105,8 @@ export function openImported(o: ImportedOptions): void { let sk = ""; let 판 = ""; let tier: Tier | null = null; + /** 우리 기초로 바꾸기 전 가져온 식 · 고른 기초(환산 계수 넣을 때 다시 바꿈) */ + let based: { src: ImportedDoc; code: string } | null = null; let dirty = false; let close = (): void => {}; let handle: ReturnType | null = null; @@ -123,6 +126,13 @@ export function openImported(o: ImportedOptions): void { }); draw(); } + /** 환산 계수 없이는 못 셈 — 시트 안 엶(셈 길 400 없음) · 머리 판 붉은 칸만 */ + function hold(doc: ImportedDoc): void { + handle?.destroy(); + handle = null; + st.doc = doc; + host.replaceChildren(createText(tx("NeedHint"), "warning", 14)); + } const now = (): ImportedDoc => (handle?.getDoc() as ImportedDoc | undefined) ?? st.doc; function draw(): void { @@ -132,13 +142,18 @@ export function openImported(o: ImportedOptions): void { } /** 서버 고침 한 번(눌림 막음) → 시트 다시 엶 */ - async function run(how: string, body: Record): Promise { + async function run( + how: string, + body: Record, + from: ImportedDoc = now(), + ): Promise { st.busy = true; draw(); try { - const got = await edit(how, { 문서: now(), ...body }); + const got = await edit(how, { 문서: from, ...body }); dirty = true; - mount(got.문서); + if (got.필요?.includes("환산")) hold(got.문서); + else mount(got.문서); return got; } catch (e) { showToast(why(e), "error"); @@ -188,10 +203,12 @@ export function openImported(o: ImportedOptions): void { return void showToast(why(e), "error"); } st.busy = false; - const got = await run("to-base", { 로직: code, 입력값: bound.입력값 }); + const src = now(); + const got = await run("to-base", { 로직: code, 입력값: bound.입력값 }, src); if (!got) return; + based = { src, code }; tier = cand?.단 ?? null; - st.need = [...bound.필요, ...(got.필요 ?? [])]; + st.need = [...new Set([...bound.필요, ...(cand?.입력필요 ?? []), ...(got.필요 ?? [])])]; st.unlinked = []; draw(); } @@ -205,9 +222,10 @@ export function openImported(o: ImportedOptions): void { const doc = now(); const v = value.trim(); dirty = true; - if (name === "환산") { - const { 환산: _old, ...rest } = doc; - return mount(v && Number(v) > 0 ? { ...rest, 환산: { 계수: Number(v) } } : rest); + if (name === "환산" && based) { + // 가져온 식에서 다시 바꿈(바꿈 전 · 후 = 서버 정본 · 옵션은 지금 값) + const swap = v && Number(v) > 0 ? { 계수: Number(v) } : null; + return void run("to-base", { 로직: based.code, 입력값: doc.입력값 ?? {}, 환산: swap }, based.src); // prettier-ignore } const kept = { ...(doc.입력값 ?? {}) }; if (v) kept[name] = v; @@ -237,7 +255,7 @@ export function openImported(o: ImportedOptions): void { variant: "ghost", onClick: async () => { if (dirty && !(await save())) return; - o.onLink!(skPick(sk, st.doc, o.target, st.need, marksOf(st.doc, tier))); + o.onLink!(skPick(sk, st.doc, st.need, marksOf(st.doc, tier))); showToast(t("Linked").replace("{k}", sk), "info"); dirty = false; close(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts index 1e7700c7b..ca4f822f6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts @@ -25,6 +25,7 @@ import { gapText, isImported, needValues, + reasonText, stillNeed, tiers, TIERS, @@ -57,6 +58,7 @@ const TEXT = { 근접: ["근접", "Near"], 사람: ["사람 고름", "Human pick"], None: ["없음", "None"], + Qty: ["품 {v}", "Qty {v}"], NoCand: ["근거 제안 없음 — [기초 고르기]로 고름", "No suggestion — pick a basis"], ToBase: ["우리 기초로 바꾸기", "Convert to our basis"], PickBase: ["기초 고르기", "Pick basis"], @@ -146,9 +148,11 @@ function cand(c: BasisCand, s: PanelState, a: PanelActs): HTMLElement { attrs: { "data-basis-cand": c.기초, "data-basis-tier": c.단 }, children: [ createBadge(`${c.기초}${c.이름 ? ` ${c.이름}` : ""}`, "accent"), + ...(c.원문번호 ? [muted(c.원문번호)] : []), ...(opts ? [muted(opts)] : []), - ...(c.차이 !== undefined ? [muted(String(c.차이))] : []), - ...(c.까닭 ? [muted(c.까닭)] : []), + ...(c.품검산 ? [createBadge(tx("Qty").replace("{v}", c.품검산), c.품검산 === "같음" ? "success" : "warning")] : []), // prettier-ignore + ...(c.입력필요?.length ? [createBadge(`${tx("Need")} ${c.입력필요.length}`, "danger")] : []), + ...(reasonText(c) ? [muted(reasonText(c))] : []), go, ], }); @@ -201,7 +205,7 @@ export function importedPanel(s: PanelState, a: PanelActs): HTMLElement { head.push("divider", muted(`${tx("Basis")} ${meta.단가기준 ?? "엑셀"}`), flip); if (s.price) { const g = tx("Gap").replace("{a}", won(s.price.전)).replace("{b}", won(s.price.후)); - const t = muted(g.replace("{d}", gapText(s.price.전, s.price.후) || "0")); + const t = muted(g.replace("{d}", gapText(s.price.전, s.price.후))); t.dataset.importedGap = ""; head.push(t); } @@ -222,7 +226,7 @@ export function importedPanel(s: PanelState, a: PanelActs): HTMLElement { const gone = meta.바꿈; if (!here && gone) { const g = tx("Gap").replace("{a}", won(gone.전)).replace("{b}", won(gone.후)); - kids.push(createBanner({ tone: "info", text: g.replace("{d}", gapText(gone.전, gone.후) || "0") })); // prettier-ignore + kids.push(createBanner({ tone: "info", text: g.replace("{d}", gapText(gone.전, gone.후)) })); // prettier-ignore } const values = needValues(s.doc); const left = stillNeed(s.need, values); diff --git a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py index 20cf6c23e..8ae88c45d 100644 --- a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py +++ b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py @@ -4,7 +4,8 @@ `실무자료/산근/.json` 에 둠 (원본 엑셀은 안 둠 · HP 지울 때 같이 지움) → [가져온 식으로 열기] = 그 블록을 793-2a 내보내기 (`M02_San_Emit` · 이음 = 1차 줄 판정) → 793-2 만들기(`M02_Sangeun_Imported.create` · 개인 층 · -엑셀 단가 그대로) · 같은 내용 SK 가 있으면 그것(다시씀). +엑셀 단가 그대로 · `가져온.근거후보` = 793-3a 근거 제안 `M02_San_Basis_Store.block` 근거후보) · +같은 내용 SK 가 있으면 그것(다시씀 · 근거후보 그대로). 블록 번 = 판정 블록 번(STmate = 코드 「D00102」 숫자 · EST = 단가산출 블록 번). STmate 블록 끝전(비목버림 | 합버림_큰나머지)은 파일 설정 — 참조 평가기로 둘 다 셈해 엑셀 계와 맞는 것. @@ -93,12 +94,18 @@ def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]: def make(source: Path, key: str, no: int, name: str = "") -> dict[str, Any]: """지금 층(만들 층)에 가져온 식 SK — 같은 내용이 있으면 그 SK(`다시씀`).""" + from M02_MasterTemplete import M02_San_Basis_Store as san_basis from M02_MasterTemplete import M02_Sangeun_Imported as imp out = emit(source, key, no) title = str(name or "").strip() or str(out["로직"].get("이름") or f"단산{no}") try: - return {**imp.create(out["로직"], out["원본"], title), "다시씀": False} + basis = san_basis.block(source, key, no).get("근거후보") or None + except Exception: # noqa: BLE001 — 근거 제안 못 셈 = 빈 칸(가져온 식은 만듦) + basis = None + try: + got = imp.create(out["로직"], out["원본"], title, basis=basis) + return {**got, "다시씀": False} except store.StoreError as e: same = (e.detail or {}).get("같은것") if isinstance(e.detail, dict) else None if e.status != 409 or not same: diff --git a/M02_MasterTemplete/M02_Sangeun_Imported.py b/M02_MasterTemplete/M02_Sangeun_Imported.py index 8d7a02804..b93320af0 100644 --- a/M02_MasterTemplete/M02_Sangeun_Imported.py +++ b/M02_MasterTemplete/M02_Sangeun_Imported.py @@ -477,7 +477,7 @@ def to_base(doc: Any, code: str, given: dict | None = None, swap: Any = None) -> # ── 만들기(개인 층) ─────────────────────────────────────────────────── def create( logic: Any, source: Any, name: str = "", unit: str = "", head: dict | None = None, - new: bool = False, use: str | None = None, + new: bool = False, use: str | None = None, basis: list | None = None, ) -> dict[str, Any]: # fmt: skip """엔진 로직 줄 + 원본 → 지금 층 새 가져온 식 SK — 서버가 다시 셈해 엑셀과 맞댐(검산 · 다르면 `같음` False 로 표지 · 막지 않음) · 같은 층 같은 내용 = 409(`ask` · 733-2).""" @@ -493,6 +493,8 @@ def create( meta = {"로직": row, "단가기준": "엑셀"} if source not in (None, {}): meta["원본"] = _meta(source, "원본") + if basis: # 근거 제안(793-3a `제안.근거후보` 그대로 · 793-3b) + meta["근거후보"] = _meta(basis, "근거후보") doc[KEY] = meta got = check(sd.clean(doc, None, whole), whole) key = ask.ask(sd.KIND, doc, new, use) or refs.create(sd.KIND, doc)["이름"] diff --git a/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts b/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts index bec68ef44..3084d4cb0 100644 --- a/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts +++ b/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts @@ -2,7 +2,7 @@ * logic/M02_Hopyo_Imported_Model.ts * 불러오기 단산 줄 = 가져온 식 단가산출(PLAN 793-3b · 요구 646 · 647) — 꼴 · 읽기 · 바꾸기만. * · SK `가져온` 덧칸 꼴(793-2 `M02_Sangeun_Imported.py` 머리) · 근거 제안 세 단(확정 · 근접 · 사람 고름 · 793-3a 가 채움) - * · 단산 줄 고른 것 = {갈래 단가산출, 키 SK-…, 층, 주인, 필요(입력 필요), 표지(결과 칩)} — 필요가 비어야 확인 · 옮김 + * · 단산 줄 고른 것 = {갈래 단가산출, 키 SK-…, 필요(입력 필요), 표지(결과 칩)} — 층 = 옮길 층 — 필요가 비어야 확인 · 옮김 * · 줄 결과 칩 = 가져온 식 있음 · 근거 확정 · 근접 · 사람 고름 · 입력 필요 n(「2차 필요」 대신) * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). * ========================================================================== */ @@ -13,17 +13,23 @@ export type Money4 = Record<"노무비" | "재료비" | "경비" | "계", number export type Tier = "확정" | "근접" | "사람"; export const TIERS: Tier[] = ["확정", "근접", "사람"]; -/** 근거 후보 하나(793-3a) */ +/** 근거 후보 하나(793-3a `제안.근거후보`) — 까닭 = [[표지(판 차이 · 현장 값 · 감률 · 할증 배수 · 단위 기준 차이 · 그 밖), 글]] */ export interface BasisCand { 기초: string; 이름?: string; + 원문번호?: string; 옵션?: Record; - 품검산?: unknown; + /** 글에 없는 칸(감률 · 할증 포함) — 사용자 입력 */ + 입력필요?: string[]; + 품검산?: "같음" | "다름" | "짝 없음" | null; 단: Tier; - 까닭?: string; - 차이?: string | number; + 까닭?: [string, string][]; } +/** 근거 후보 까닭 글(「표지 글」 · 줄마다) */ +export const reasonText = (c: BasisCand): string => + (c.까닭 ?? []).map(([tag, text]) => `${tag} ${text}`.trim()).join(" · "); + export interface CheckLine { 자리: string; 행?: number | null; @@ -93,11 +99,10 @@ export const needValues = (d: ImportedDoc): Record => ({ export const stillNeed = (need: string[], values: Record | undefined): string[] => need.filter((k) => blank(values?.[k])); -/** 단산 줄 고른 것 — 그 SK(필요 = 빈 입력 필요 칸 · `missing` 이 막음) */ +/** 단산 줄 고른 것 — 그 SK(필요 = 빈 입력 필요 칸 · `missing` 이 막음) · 층 · 주인 안 둠 = 옮길 층(SK 를 만든 층)의 SK */ export function skPick( sk: string, doc: ImportedDoc, - scope: { layer: string; owner: string | null }, need: string[], marks: string[], ): DraftPick & { 표지: string[] } { @@ -110,8 +115,6 @@ export function skPick( 입력값: Object.fromEntries(need.map((k) => [k, values[k] ?? ""])), 출처: {}, 몫: null, - 층: scope.layer, - 주인: scope.owner, 필요: need, 표지: marks, }; @@ -136,7 +139,7 @@ export function gapText( before: Money4 | null | undefined, after: Money4 | null | undefined, ): string { - if (!before || !after) return ""; + if (!before || !after) return "—"; const d = after.계 - before.계; return `${d > 0 ? "+" : ""}${d.toLocaleString("ko-KR")}`; } diff --git a/resources/tester/test_793_3b_import_screen.py b/resources/tester/test_793_3b_import_screen.py index d35e2b165..5f5ac019b 100644 --- a/resources/tester/test_793_3b_import_screen.py +++ b/resources/tester/test_793_3b_import_screen.py @@ -66,6 +66,10 @@ def test_단산_블록_가져온_식_만들기_다시씀_지움(client): with store.at(folder): doc = store.read("sangeun", got["이름"])["문서"] assert doc["바탕"] is None and doc["가져온"]["원본"]["엑셀금액"]["계"] == 30369 + ten = _open(client, 10).json() # 야면석소운반 — 793-3a 근거 제안 = SK 가져온.근거후보 + with store.at(folder): + cand = store.read("sangeun", ten["이름"])["문서"]["가져온"]["근거후보"] + assert cand and cand[0]["기초"] == "GC001140" and cand[0]["단"] in ("확정", "근접", "사람") assert _open(client, 99999).status_code == 404 fh._path(folder, KEY).unlink() # 산근 없는 옛 HP = 다시 올림 old = _open(client) @@ -188,3 +192,24 @@ def test_우리_기초로_바꾸기_환산_입력_필요(tmp_path): "필요" not in got and round(got["후"]["계"]) == 94227 and got["문서"]["환산"] == {"계수": 1} ) assert got["문서"]["바탕"] == {"로직": "GC001140"} and got["문서"][imp.KEY]["바꿈"]["전"] + + +def test_이_줄에_잇기_옮기기_SK_참조(client): + """[이 줄에 잇기] 고른 것 {갈래 단가산출, 키 SK}(층 · 주인 없음) → 옮기기 = 그 SK 참조 줄.""" + from M02_MasterTemplete import M02_Hopyo_Judge as judge + + client.app.include_router(sangeun_router.router) + assert _post(client).status_code == 200 + folder = layers.personal_dir(7, 42) + judge.solve_all(folder, KEY) + sk = _open(client, 10).json()["이름"] + pick = {"갈래": "단가산출", "키": sk, "이름": "야면석소운반", "단위": "㎡"} # 화면 skPick 꼴 + pick |= {"입력값": {}, "출처": {}, "몫": None, "필요": [], "표지": ["가져온 식 있음"]} + body = {"층": "personal", "호표": {"2": {"3": [pick]}}, "블록": {}} + got = client.post(f"{API}/hopyo/{KEY}/move", json=body) + assert got.status_code == 200, got.text + sc = got.json()["만듦"][0]["키"] + with store.at(folder): + lines = store.read("precombo", sc)["문서"]["담은로직"] + refs = [x["참조"]["키"] for x in lines if x.get("참조")] + assert got.json()["막힘"] == [] and refs == [sk], got.json()["막힘"] From 80c3f72a248e52cbe2232b957107c5b931d8080b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 18:44:03 +0900 Subject: [PATCH 2/2] =?UTF-8?q?perf(792-2):=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EC=85=88=20=EC=84=9C=EB=B2=84=20=EB=8B=A4?= =?UTF-8?q?=EB=93=AC=EA=B8=B0=20=E2=80=94=20=EC=9E=90=EC=9E=AC=20=EC=83=89?= =?UTF-8?q?=EC=9D=B8=20=C2=B7=20=EC=B0=BE=EA=B8=B0=20=EB=8B=B5=EC=9D=84=20?= =?UTF-8?q?M01=20=EC=83=89=EC=9D=B8=EC=97=90=20=ED=95=9C=20=EB=B2=88=20?= =?UTF-8?q?=C2=B7=20=ED=95=A9=ED=8C=90=201.2~1.6=20s=20=E2=86=92=2062=20ms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `_Over` 덮개가 자재 색인(`_market` · `_by_class`) · 찾기 답(`_found`)을 속 M01 색인에 둠 — 셈마다 3만 줄 다시 안 지음 · M01 파일 자국이 바뀌면 새 색인 = 새로 - `master_pick.search` = 마스터 · 조건 · 지역 · 단위마다 한 번(`_search` 가 옛 몸) · 사본을 줌 - `calc_doc` = M01 `steady()` 안 — 한 셈 동안 파일 자국 한 번(37 번 → 1) - 측정(8001 · 10 번 중앙값 · 옵션/상수/식/변수 줄) 합판 62/62/62/66 · 지게 30 · 굴착기 31~36 · 봉화 27 ms · 금액 전후 같음 - 엔진 일곱 번은 남김 — 한 번 ~5 ms · 목표 아래 - 시험 test_792_2_calc_speed(색인 다시 안 지음 · 자국 바뀌면 다시 · 층 섞임 없음 · 셈 답 같음) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Sangeun_Calc.py | 30 +++-- resources/master_data/scripts/master_pick.py | 13 ++ resources/tester/test_792_2_calc_speed.py | 122 +++++++++++++++++++ 3 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 resources/tester/test_792_2_calc_speed.py diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index 8dd8e61db..ca53d62f8 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -41,6 +41,7 @@ from M02_MasterTemplete import M02_Sangeun_Views as views COSTS = labor.COSTS Ref = dict[str, Any] +_SHARED = ("_market", "_by_class", "_found") # 마스터에만 따르는 자재 색인 · 찾기 답 class _Over: @@ -55,6 +56,14 @@ class _Over: def __getattr__(self, name: str) -> Any: return getattr(self._whole, name) + def __setattr__(self, name: str, value: Any) -> None: + """자재 색인(`master_material`)은 속 M01 색인에 둠 — 셈마다 다시 안 지음(792-2) · + M01 파일 자국이 바뀌면 새 색인 = 새로 지음 · 그 밖은 이 덮개에만.""" + if name in _SHARED: + setattr(self._whole, name, value) + else: + super().__setattr__(name, value) + def _fail(e: sd.DocError) -> store.StoreError: return store.StoreError(e.status, str(e)) @@ -570,16 +579,17 @@ def calc_doc(doc: Any, given: dict | None = None, seen: bool = True) -> dict[str `seen` = 보기 덧칸(`글` · `중간`) 지음.""" from M01_MasterData import M01_MasterData_Store as m01 - whole = m01.loaded()[1] - try: - clean = sd.clean(doc, None, whole) - _check_refs(clean, refs.here()) # 저장 전 문서도 위 층만 · 있는 문서 · 순환 422 - me = refs.ref_id({"층": refs.here()[0], "주인": refs.here()[1], "키": clean["키"]}) - line = isinstance(doc, dict) and doc.get("줄모드") is True # 일위대가 줄 모달(750-1) - got = run(clean, whole, given, trail=(me,), detail=True, line=line, seen=seen) - except sd.DocError as e: - raise _fail(e) from e - return const.answer(got, clean, doc, given, (me,)) # 옛 답 + 원래 · 시트(790-2) + with m01.cache.steady(): # 한 셈 동안 M01 파일 자국 한 번(792-2) + whole = m01.loaded()[1] + try: + clean = sd.clean(doc, None, whole) + _check_refs(clean, refs.here()) # 저장 전 문서도 위 층만 · 있는 문서 · 순환 422 + me = refs.ref_id({"층": refs.here()[0], "주인": refs.here()[1], "키": clean["키"]}) + line = isinstance(doc, dict) and doc.get("줄모드") is True # 일위대가 줄 모달(750-1) + got = run(clean, whole, given, trail=(me,), detail=True, line=line, seen=seen) + except sd.DocError as e: + raise _fail(e) from e + return const.answer(got, clean, doc, given, (me,)) # 옛 답 + 원래 · 시트(790-2) def from_line( diff --git a/resources/master_data/scripts/master_pick.py b/resources/master_data/scripts/master_pick.py index 6b9b38599..6f6a2b7c1 100644 --- a/resources/master_data/scripts/master_pick.py +++ b/resources/master_data/scripts/master_pick.py @@ -67,6 +67,19 @@ def _words(term) -> tuple[list[str], list[str]]: def search(master, cond: dict, env: dict | None = None, unit: str | None = None) -> list[str]: + """`_search` 를 마스터 색인 하나에 조건마다 한 번만(792-2) — + 답은 마스터 · 조건 · 지역 · 단위만 따름.""" + place = mm.plain(env[mm.REGION]) if (env or {}).get(mm.REGION) else "" + memo = getattr(master, "_found", None) + if memo is None: + memo = master._found = {} + key = tuple(str(cond.get(k) or "") for k in ("검색어", "구분", "상세구분")) + (place, str(unit)) + if key not in memo: + memo[key] = _search(master, cond, env, unit) + return list(memo[key]) + + +def _search(master, cond: dict, env: dict | None = None, unit: str | None = None) -> list[str]: """검색어 안 자재품목 키 — 낱말이 모두 이름 또는 규격에 든 줄(요소의 구분 · 상세구분 안에서) · 단위는 같은 성격까지(하나도 없으면 다 — 경고가 붙음) · 고르는 차례: 값 있는 줄 → 줄 단위와 똑같은 단위 → 지역 낱말(입력 `자재지역` 포함)과 맞는 줄 → 키 차례.""" diff --git a/resources/tester/test_792_2_calc_speed.py b/resources/tester/test_792_2_calc_speed.py new file mode 100644 index 000000000..3c61d56a3 --- /dev/null +++ b/resources/tester/test_792_2_calc_speed.py @@ -0,0 +1,122 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""792-2 시트 다시 셈 서버 다듬기 — 자재 색인 · 찾기 답을 M01 색인에 한 번만. + +- 셈마다 새 `_Over` 덮개를 지어도 자재 색인(`_market` · `_by_class`) · 찾기 답(`_found`)은 속 M01 + 색인에 붙음 — 두 번째 셈은 다시 안 지음 +- M01 파일 자국이 바뀌면 새 색인 = 새로 지음(지문 열쇠 = `M01_MasterData_Store_Cache.loaded`) +- 층 섞임 없음 — 기억은 M01 자재품목 키만 · 개인 자재(PM)는 그때 층에서만 찾음 +- 셈 답 같음 — 기억한 찾기 = 날 찾기 · SK 금액 견본(합판거푸집 51,419 · 공구손료 2 % 51,802) +""" + +from __future__ import annotations + +import json +import os +from decimal import Decimal + +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Cache as cache +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Material_Own as own +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +mm = m01.cm.mf.mp.mm +mp = m01.cm.mf.mp +F211 = { + "자재지역": "포항", + "유형": "보통", + "사용횟수": "6회", + "높이감률": 0, + "지붕슬래브": "아님", + "폼타이": "아님", + "폼타이규격": "12.7㎜", + "측압": "3t/㎡", + "구멍땜": 0, +} + + +def _sk(**more): + doc = {"키": "SK-000001", "이름": "속도", "단위": "식", "바탕": {"로직": "GC000211"}} + return {**doc, "입력값": F211, **more} + + +def test_색인은_M01_색인에_한_번_두번째_셈은_안_지음(monkeypatch): + first = calc.calc_doc(_sk()) + whole = m01.loaded()[1] + held = {n: vars(whole)[n] for n in ("_market", "_found")} + fresh = mp._search + found = [] + monkeypatch.setattr(mp, "_search", lambda *a: found.append(a) or fresh(*a)) + again = calc.calc_doc(_sk()) + assert again["기준"] == first["기준"] and first["기준"]["계"] == 51419 + assert all(vars(whole)[n] is v for n, v in held.items()) + assert found == [] # 같은 조건 찾기 = 기억한 답(자재품목 3만 줄을 안 훑음) + over = calc._Over(whole, "SK-000001", {}) + assert over._market is whole._market and "_market" not in vars(over) + over.mine = 1 # 그 밖 칸은 덮개에만 + assert "mine" not in vars(whole) + + +def test_M01_파일이_바뀌면_새_색인_새로_지음(tmp_path): + def write(rows): + path = tmp_path / "자재품목.json" + path.write_text(json.dumps({"그룹": "재료", "줄": rows}, ensure_ascii=False), "utf-8") + return path + + one = {"키": "MT000001", "이름": "합판", "규격": "12mm", "단위": "장", "물가자료": 100} + two = {**one, "키": "MT000002", "이름": "각재"} + try: + write([one]) + old = cache.loaded(tmp_path)[1] + assert [k for *_, k in mm._market_index(old)] == ["MT000001"] + path = write([one, two]) + os.utime(path, ns=(1, 1)) # 자국(시각 · 크기)이 꼭 바뀌게 + new = cache.loaded(tmp_path)[1] + assert new is not old and not hasattr(new, "_market") + assert [k for *_, k in mm._market_index(new)] == ["MT000001", "MT000002"] + assert cache.loaded(tmp_path)[1] is new # 안 바뀌면 그대로 + finally: + cache.forget() # 다음 시험은 M01 폴더를 다시 읽음 + + +def test_기억한_찾기_답은_날_찾기와_같고_사본을_줌(): + whole = m01.loaded()[1] + conds = [ + ({"검색어": "합판 12mm", "구분": "목재"}, {}, "㎡"), + ({"검색어": "철선"}, {mm.REGION: "포항"}, "kg"), + ({"검색어": "레미콘"}, {mm.REGION: "서울"}, None), + ] + for cond, env, unit in conds: + got = mp.search(whole, cond, env, unit) + assert got == mp._search(whole, cond, env, unit) + got.append("MT999999") + assert "MT999999" not in mp.search(whole, cond, env, unit) + assert mp.search(whole, conds[1][0], {mm.REGION: "서울"}, "kg") == mp._search( + whole, conds[1][0], {mm.REGION: "서울"}, "kg" + ) + + +def test_층_섞임_없음_개인_자재는_그때_층에서만(world): + calc.calc_doc(_sk()) # M01 색인에 기억이 생긴 뒤 + whole = m01.loaded()[1] + assert all(k.startswith(mm.MARKET) for *_, k in whole._market) + assert all(k.startswith(mm.MARKET) for keys in whole._found.values() for k in keys) + with store.at(world["personal"]): + code = own.add(world["personal"], {"이름": "내 자재", "단위": "개", "값": 1234})["키"] + mine = labor.material_price(whole, code, "개") + other = world["personal"].parent.parent / "43" / world["personal"].name + other.mkdir(parents=True) + with store.at(other): + theirs = labor.material_price(whole, code, "개") + assert Decimal(str(mine["기준단가"])) == 1234 + assert theirs["기준단가"] is None + assert code not in whole.index.get(code[:2], {}) + + +def test_상수_덮개_셈_답_전후_같음(): + got = calc.calc_doc(_sk(상수덮개={"pct:공구손료 및 경장비#0": {"0": 2}})) + assert got["기준"]["계"] == 51802 + assert got["원래"]["계"] == 51419 + assert got["시트"]["자리"]["pct:공구손료 및 경장비#0"]["덮개"] == ["상수"]