From ea21006352601a9b97689e2ca54f824a5eb670a6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 23:15:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20338-3a=20=EB=B3=84=EC=B9=AD=20?= =?UTF-8?q?=EC=9E=A5=EB=B6=80=20=EB=BC=88=EB=8C=80=20=E2=80=94=20=EC=97=B4?= =?UTF-8?q?=EC=87=A0=20=C2=B7=20=EC=B8=B5=20=EC=B0=A8=EB=A1=80=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=8A=90=EC=8A=A8=20=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=98=9B=20=EA=BC=B4=20=EB=B0=94=EA=BF=94=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EC=9E=AC=EA=B2=80=EC=82=AC=20=C2=B7=20?= =?UTF-8?q?=EC=93=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Hopyo_Ledger.py — key · hopyo_key · recall(같은 층 → 위 층 · 품명|단위 느슨 = 규격다름) · books · rows · check · write · put - 옛 기억 줄(로직|자재|빼기 · 환산 · 확정) = 새 꼴(갈래 짝 · 짝 [{… 배}] · 빼기 = 안 씀)로 읽음 · 쓸 때 새 꼴 - 시험 test_m02_hopyo_ledger.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- M02_MasterTemplete/M02_Hopyo_Ledger.py | 261 ++++++++++++++++++++++ resources/tester/test_m02_hopyo_ledger.py | 149 ++++++++++++ 2 files changed, 410 insertions(+) create mode 100644 M02_MasterTemplete/M02_Hopyo_Ledger.py create mode 100644 resources/tester/test_m02_hopyo_ledger.py diff --git a/M02_MasterTemplete/M02_Hopyo_Ledger.py b/M02_MasterTemplete/M02_Hopyo_Ledger.py new file mode 100644 index 000000000..0b97c4b1c --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Ledger.py @@ -0,0 +1,261 @@ +"""M02 일위대가 실무자료 별칭 장부(PLAN 338-3 · 설계 3-5) — 층 `실무자료/맞춤.json` 을 키움. + +줄 = {열쇠(품명|규격|단위 정규화): {원래 {품명, 규격, 단위}, + 갈래 「짝」 · 「우리에 없음」 · 「안 씀」, 짝 [{로직|자재, 입력값, 배, 몫}], + 누가, 때, 처음파일, 엑셀조항, 판}} · +직접 줄 호표 통째 = 열쇠 「호표|품명|규격|단위」. +옛 줄 {로직|자재|빼기, 입력값, 환산, 확정} = 읽을 때 새 꼴로 바꿔 읽음(빼기 = 「안 씀」 · +파일은 다음 쓰기 때 새 꼴). +읽는 차례 = 같은 층 → 위 층(개인 → 회사 → 마스터) · 아래 층이 이김 · 정확 열쇠가 먼저 · 없으면 +품명|단위 느슨 열쇠(`규격다름`). +쓰는 때 = [확인하고 사전조합 만들기] · 장부 화면 [저장]만 · 쓰기 = 그 층만(권한은 길 몫) · +짝은 서버가 다시 검사(돈 로직 · 그 로직 입력 이름 · M01 자재품목 · 배 0 넘음 · 몫 (0, 1]). +""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +mm = m01.cm.mf.mp.mm +KINDS = ("짝", "우리에 없음", "안 씀") +HOPYO = "호표|" + + +# ── 열쇠 ───────────────────────────────────────────────────────────── +def key(line: dict[str, Any]) -> str: + """줄 열쇠 = 품명 | 규격 | 단위(정규화 · `M02_Hopyo_Match.line_key` 와 같음).""" + return "|".join( + ( + mm.plain(line.get("이름")), + mm.plain_spec(line.get("규격")), + mm.plain_unit(line.get("단위")), + ) + ) + + +def hopyo_key(hopyo: dict[str, Any]) -> str: + """직접 줄 호표 통째 열쇠 — 「호표|품명|규격|단위」.""" + return HOPYO + key(hopyo) + + +def _loose(full: str) -> str: + """느슨 열쇠 = 품명 | 단위(규격 뺌).""" + head, _, rest = full.rpartition("|") + return f"{head.rsplit('|', 1)[0]}|{rest}" + + +# ── 옛 꼴 → 새 꼴 ───────────────────────────────────────────────────── +def _plain_of(full: str) -> dict[str, str]: + name, spec, unit = full.removeprefix(HOPYO).split("|") + return {"품명": name, "규격": spec, "단위": unit} + + +def upgrade(full: str, raw: Any) -> dict[str, Any] | None: + """장부 한 줄을 새 꼴로 — 옛 기억 줄 · 새 꼴 · 모르는 꼴(None).""" + if not isinstance(raw, dict): + return None + if raw.get("갈래") in KINDS: + return {"원래": _plain_of(full), "짝": [], **raw} + head = {"원래": _plain_of(full), "때": raw.get("확정"), "누가": None, "처음파일": None} + if raw.get("빼기"): + return {**head, "갈래": "안 씀", "짝": []} + pair = {"로직": raw["로직"]} if raw.get("로직") else {"자재": raw.get("자재")} + if not any(pair.values()): + return None + pair.update(입력값=raw.get("입력값") or {}, 배=raw.get("환산"), 몫=None) + return {**head, "갈래": "짝", "짝": [pair]} + + +def read(folder: str | Path) -> dict[str, dict[str, Any]]: + """한 층 장부 `{열쇠: 새 꼴 줄}`(옛 꼴은 바꿔 읽음 · 모르는 줄 뺌).""" + out = {} + for full, raw in hopyo_store.memory(folder).items(): + if str(full).removeprefix(HOPYO).count("|") == 2 and (got := upgrade(str(full), raw)): + out[str(full)] = got + return out + + +# ── 층 ─────────────────────────────────────────────────────────────── +def chain(folder: str | Path) -> list[Path]: + """장부를 볼 층 폴더 — 같은 층 → 위 층(개인 → 회사 → 마스터).""" + layer, owner = refs.where(folder) + out = [Path(folder)] + if layer == "personal": + out.append(refs.folder_of("company", str(owner).split("/")[0])) + if layer in ("personal", "company"): + out.append(Path(store.FOLDER)) + return out + + +def books(folder: str | Path) -> list[tuple[str, str | None, dict[str, dict[str, Any]]]]: + """층마다 `(층, 주인, 장부)` — 같은 층부터(줄 여럿을 찾을 때 한 번 읽어 넘김).""" + return [(*refs.where(one), read(one)) for one in chain(folder)] + + +def recall( + line: dict[str, Any], folder: str | Path, got: list | None = None, whole: bool = False +) -> dict[str, Any] | None: + """줄(`whole` = 호표 통째)의 장부 짝 — `{…줄, 층, 주인, 규격다름}` · 없으면 None. + 정확 열쇠(아래 층 먼저) → 품명|단위 느슨(규격다름 True · 체크 안 함).""" + full = hopyo_key(line) if whole else key(line) + got = books(folder) if got is None else got + for layer, owner, book in got: + if full in book: + return {**book[full], "층": layer, "주인": owner, "규격다름": False} + loose = _loose(full) + for layer, owner, book in got: + for one, row in book.items(): + if one.startswith(HOPYO) == whole and _loose(one) == loose: + return {**row, "층": layer, "주인": owner, "규격다름": True} + return None + + +def rows(folder: str | Path, writable: bool) -> list[dict[str, Any]]: + """장부 화면 — 이 층 + 위 층 줄 `{열쇠, …줄, 층, 주인, 고침}`(고침 = 이 층 · 쓰기 권한).""" + out = [] + for at, (layer, owner, book) in enumerate(books(folder)): + for full, row in sorted(book.items()): + out.append( + {"열쇠": full, **row, "층": layer, "주인": owner, "고침": writable and not at} + ) + return out + + +# ── 검사 · 쓰기 ─────────────────────────────────────────────────────── +def _bad(text: str) -> store.StoreError: + return store.StoreError(400, text) + + +def _number(raw: Any) -> Decimal | None: + if isinstance(raw, bool): + return None + try: + got = Decimal(str(raw).replace(",", "").strip()) + except ArithmeticError: + return None + return got if got.is_finite() else None + + +def _json(value: Decimal) -> int | float: + value = value.normalize() + return int(value) if value == value.to_integral_value() else float(value) + + +def _known() -> tuple[set[str], set[str]]: + """(돈 로직 키, M01 자재품목 키) — 맞춤 목록 한 벌(`M02_Hopyo_Match.targets`).""" + from M02_MasterTemplete import M02_Hopyo_Match as match # 맞춤이 장부를 부름 — 늦게 읽음 + + got = match.targets() + return {x["로직"] for x in got["로직"]}, {x["자재"] for x in got["자재"]} + + +def input_names(code: str) -> set[str]: + got = labor_inputs.logic_inputs([code])["로직"] + return {str(x.get("이름")) for x in (got[0]["입력"] if got else [])} + + +def check_pair(raw: Any, where: str, known: tuple[set[str], set[str]]) -> dict[str, Any]: + """짝 하나 재검사 → `{로직|자재, 입력값, 배, 몫}` — 틀리면 400.""" + if not isinstance(raw, dict): + raise _bad(f"짝 꼴이 틀림 「{where}」") + extra = set(raw) - {"로직", "자재", "입력값", "배", "몫", "이름", "단위"} + if extra: + raise _bad(f"모르는 칸 {sorted(extra)} 「{where}」") + code, mat = str(raw.get("로직") or ""), str(raw.get("자재") or "") + if bool(code) == bool(mat): + raise _bad(f"돈 로직 · 우리 자재 하나만 「{where}」") + if (code and code not in known[0]) or (mat and mat not in known[1]): + raise _bad(f"없는 {'로직' if code else '자재'} 「{code or mat}」 「{where}」") + values = raw.get("입력값") or {} + if not isinstance(values, dict) or (mat and values): + raise _bad(f"입력값이 틀림 「{where}」") + unknown = set(values) - input_names(code) if code else set() + if unknown: + raise _bad(f"모르는 입력 {sorted(unknown)} 「{where}」") + times = _number(raw.get("배", 1)) + if times is None or times <= 0: + raise _bad(f"배는 0 넘는 수 「{where}」") + share = raw.get("몫") + part = None if share is None else _number(share) + if share is not None and (part is None or not 0 < part <= 1): + raise _bad(f"몫은 0 넘고 1 이하 「{where}」") + head = {"로직": code} if code else {"자재": mat} + return { + **head, + "입력값": values, + "배": _json(times), + "몫": None if part is None else _json(part), + } + + +def check(full: str, raw: Any, known: tuple[set[str], set[str]] | None = None) -> dict[str, Any]: + """장부 한 줄 재검사(장부 화면 [저장]) → 쓸 꼴(누가 · 때 빼고).""" + if str(full).removeprefix(HOPYO).count("|") != 2 or not isinstance(raw, dict): + raise _bad(f"장부 꼴이 틀림 「{full}」") + kind = raw.get("갈래") + if kind not in KINDS: + raise _bad(f"갈래는 {' · '.join(KINDS)} 「{full}」") + pairs = raw.get("짝") or [] + if kind != "짝" and pairs: + raise _bad(f"「{kind}」 줄은 짝 없음 「{full}」") + if kind == "짝" and (not isinstance(pairs, list) or not pairs or len(pairs) > 20): + raise _bad(f"짝은 하나 넘고 스물 이하 「{full}」") + known = known or _known() + origin = raw.get("원래") if isinstance(raw.get("원래"), dict) else _plain_of(full) + return { + "원래": {k: str(origin.get(k) or "") for k in ("품명", "규격", "단위")}, + "갈래": kind, + "짝": [check_pair(p, full, known) for p in pairs], + "엑셀조항": raw.get("엑셀조항") if isinstance(raw.get("엑셀조항"), list) else [], + } + + +def _version() -> str | None: + """마스터 판 열쇠(가 창 `M02_Hopyo_Ours.version` — 아직 없으면 None).""" + try: + from M02_MasterTemplete import M02_Hopyo_Ours as ours + except ImportError: + return None + return ours.version() if hasattr(ours, "version") else None + + +def write( + folder: str | Path, + entries: dict[str, dict[str, Any] | None], + who: Any, + file: str | None = None, +) -> int: + """검사 끝난 줄을 층 장부에 씀(None = 지움) — 같은 열쇠는 덮음 · 처음파일은 있던 값 둠 · + 옛 꼴 줄은 이때 새 꼴로 → 쓴 줄 수.""" + if not entries: + return 0 + have = read(folder) + stamp = datetime.now().isoformat(timespec="seconds") + version = _version() + out: dict[str, dict[str, Any] | None] = dict(have) # 옛 꼴도 새 꼴로 다시 씀 + for full, row in entries.items(): + if row is None: + out[full] = None + continue + first = (have.get(full) or {}).get("처음파일") or file + out[full] = {**row, "누가": who, "때": stamp, "처음파일": first, "판": version} + hopyo_store.remember(folder, out) + return len(entries) + + +def put(folder: str | Path, entries: Any, who: Any) -> dict[str, Any]: + """장부 화면 [저장] — `{열쇠: 줄 | None}` 재검사 뒤 이 층에 씀 → `{씀, 줄}`.""" + if not isinstance(entries, dict) or not entries or len(entries) > 2000: + raise _bad("저장할 줄이 없음") + known = _known() + clean = {str(k): None if v is None else check(str(k), v, known) for k, v in entries.items()} + return {"씀": write(folder, clean, who), "줄": rows(folder, True)} diff --git a/resources/tester/test_m02_hopyo_ledger.py b/resources/tester/test_m02_hopyo_ledger.py new file mode 100644 index 000000000..d60b5b239 --- /dev/null +++ b/resources/tester/test_m02_hopyo_ledger.py @@ -0,0 +1,149 @@ +"""일위대가 실무자료 별칭 장부(PLAN 338-3 · `M02_Hopyo_Ledger`) — 옛 꼴 바꿔 읽음 · 새 꼴 · +층 차례(아래 층이 이김) · 느슨 찾기(규격다름) · 우리에 없음 · 안 씀 · 호표 통째 열쇠 · +위 층 읽기만 · 재검사 · 쓰기(처음파일 둠 · 옛 꼴 새로 씀) · master_data 원본 그대로. 층은 임시 폴더. +""" + +import json + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Ledger as ledger +from M02_MasterTemplete import M02_Hopyo_Match as hm +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers + +# 올리기 길 시험의 `client` 마당을 같이 씀 +# ruff: noqa: F811 +from resources.tester.test_m02_hopyo_excel import client, master_hash # noqa: F401 + +DIG = {"이름": "구조물터파기(인력100%)", "규격": "토사", "단위": "㎥"} +DIG_KEY = "구조물터파기(인력100%)|토사|m3" +WHO = {"id": 42, "이름": "갑"} + + +def _put_raw(folder, rows): + path = folder / hs.DIRNAME / f"{hs.MEMORY}.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"줄": rows}, ensure_ascii=False), encoding="utf-8") + + +def _raw(folder): + return json.loads((folder / hs.DIRNAME / f"{hs.MEMORY}.json").read_bytes())["줄"] + + +def test_열쇠는_맞춤과_같음(): + assert ledger.key(DIG) == hm.line_key(DIG) == DIG_KEY + assert ledger.hopyo_key(DIG) == "호표|" + DIG_KEY + assert ledger._loose(DIG_KEY) == "구조물터파기(인력100%)|m3" + assert ledger._loose("호표|" + DIG_KEY) == "호표|구조물터파기(인력100%)|m3" + + +def test_옛_꼴_바꿔_읽음(client): + mine = layers.personal_dir(7, 42) + _put_raw( + mine, + { + DIG_KEY: {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 2, "확정": "t1"}, + "합판거푸집|4회|m2": {"빼기": True, "확정": "2026-10-02T10:00:00"}, + "표지석||개": {"자재": "MT004258", "입력값": {}, "환산": 1, "확정": "t"}, + "깨진|줄": {"로직": "X"}, + "빈|짝|m": {"입력값": {}}, + }, + ) # fmt: skip + got = ledger.read(mine) + assert set(got) == {DIG_KEY, "합판거푸집|4회|m2", "표지석||개"} # 모르는 꼴 뺌 + assert got[DIG_KEY] == { + "원래": {"품명": "구조물터파기(인력100%)", "규격": "토사", "단위": "m3"}, + "때": "t1", + "누가": None, + "처음파일": None, + "갈래": "짝", + "짝": [{"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 2, "몫": None}], + } + assert got["합판거푸집|4회|m2"]["갈래"] == "안 씀" and got["합판거푸집|4회|m2"]["짝"] == [] + assert got["표지석||개"]["짝"] == [{"자재": "MT004258", "입력값": {}, "배": 1, "몫": None}] + + +def test_층_차례_아래_층이_이김_느슨_찾기(client): + mine, company, system = ledger.chain(layers.personal_dir(7, 42)) + assert (company, system) == (layers.company_dir(7), store.FOLDER) + new = {"갈래": "짝", "짝": [{"로직": "GF000103", "입력값": {}, "배": 1, "몫": None}]} + _put_raw(system, {DIG_KEY: new, "잔토처리(인력100%)||m3": {"갈래": "우리에 없음"}}) + _put_raw(company, {DIG_KEY: {**new, "짝": [{"로직": "GF000102", "배": 1}]}}) + got = ledger.recall(DIG, mine) + assert (got["층"], got["주인"], got["짝"][0]["로직"], got["규격다름"]) == ( + "company", + "7", + "GF000102", + False, + ) + _put_raw(mine, {DIG_KEY: {"로직": "GF000101", "환산": 1}}) + assert ledger.recall(DIG, mine)["짝"][0]["로직"] == "GF000101" # 같은 층 먼저 + rock = {**DIG, "규격": "경암"} + loose = ledger.recall(rock, mine) + assert loose["규격다름"] is True and loose["층"] == "personal" # 품명|단위 느슨 + soil = {"이름": "잔토처리(인력100%)", "규격": "", "단위": "M3"} + assert ledger.recall(soil, mine)["갈래"] == "우리에 없음" + assert ledger.recall({"이름": "없는 줄", "단위": "m"}, mine) is None + assert ledger.recall(DIG, mine, whole=True) is None # 호표 통째 열쇠는 따로 + books = ledger.books(mine) + assert ledger.recall(DIG, mine, books)["짝"][0]["로직"] == "GF000101" # 한 번 읽어 넘김 + rows = ledger.rows(mine, True) + assert [(r["층"], r["고침"]) for r in rows] == [ + ("personal", True), + ("company", False), + ("system", False), + ("system", False), + ] # 위 층 = 읽기만 + assert not any(r["고침"] for r in ledger.rows(mine, False)) + + +def test_재검사(client): + known = ledger._known() + ok = { + "갈래": "짝", + "짝": [{"로직": "GF000101", "입력값": {"지역": "서울"}, "배": "2", "몫": 0.5}], + } + got = ledger.check(DIG_KEY, ok, known) + assert got["짝"] == [{"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 2, "몫": 0.5}] + assert got["원래"] == {"품명": "구조물터파기(인력100%)", "규격": "토사", "단위": "m3"} + assert ledger.check(DIG_KEY, {"갈래": "안 씀"}, known)["짝"] == [] + pair = ok["짝"][0] + bad = [ + ("a|b", ok), + (DIG_KEY, {"갈래": "같음", "짝": ok["짝"]}), + (DIG_KEY, {"갈래": "짝", "짝": []}), + (DIG_KEY, {"갈래": "안 씀", "짝": ok["짝"]}), + (DIG_KEY, {"갈래": "짝", "짝": [{**pair, "로직": "GC000422"}]}), # 돈 아닌 로직 + (DIG_KEY, {"갈래": "짝", "짝": [{**pair, "입력값": {"없는칸": 1}}]}), + (DIG_KEY, {"갈래": "짝", "짝": [{**pair, "배": 0}]}), + (DIG_KEY, {"갈래": "짝", "짝": [{**pair, "배": "nan"}]}), + (DIG_KEY, {"갈래": "짝", "짝": [{**pair, "몫": 1.5}]}), + (DIG_KEY, {"갈래": "짝", "짝": [{**pair, "자재": "MT004258"}]}), + (DIG_KEY, {"갈래": "짝", "짝": [{**pair, "금액": 100}]}), # 금액 칸 받지 않음 + (DIG_KEY, {"갈래": "짝", "짝": [{"자재": "MT000000", "배": 1}]}), + ] + for full, raw in bad: + with pytest.raises(store.StoreError) as error: + ledger.check(full, raw, known) + assert error.value.status == 400, raw + + +def test_쓰기_처음파일_옛_꼴_새로(client): + before = master_hash() + mine = layers.personal_dir(7, 42) + _put_raw(mine, {"합판거푸집|4회|m2": {"빼기": True, "확정": "t"}}) + row = ledger.check(DIG_KEY, {"갈래": "짝", "짝": [{"로직": "GF000101", "배": 1}]}) + assert ledger.write(mine, {DIG_KEY: row}, WHO, "가.xls") == 1 + raw = _raw(mine) + assert raw["합판거푸집|4회|m2"]["갈래"] == "안 씀" # 옛 꼴 = 쓸 때 새 꼴 + assert raw[DIG_KEY]["누가"] == WHO and raw[DIG_KEY]["처음파일"] == "가.xls" + assert raw[DIG_KEY]["때"] + ledger.write(mine, {DIG_KEY: row}, WHO, "나.xls") + assert _raw(mine)[DIG_KEY]["처음파일"] == "가.xls" # 처음파일은 있던 값 + got = ledger.put(mine, {DIG_KEY: None}, WHO) + assert got["씀"] == 1 and DIG_KEY not in _raw(mine) + with pytest.raises(store.StoreError): + ledger.put(mine, {}, WHO) + assert master_hash() == before