Files
Aislo/M02_MasterTemplete/M02_San_MatPick.py
T
eomsangdonandClaude Opus 5.5 520bb33909 feat(827-3): 불러오기 재료 걸음 3 — 고를 재료 판 · 그 파일 안 고름(별칭 아님) · SK 상세 같은 파일 물음
- 새 M02_San_MatPick: 종 열쇠(이름 · 규격 · 단위 · 단서) · 고름 자리 = 개인 층 실무자료/재료고름/<파일 지문>.json([끝내기] 뒤 남음 · 같은 파일 다시 올리면 그대로) · 판(블록마다 재료찾기 판정 → 종 · 첫 판 한 번 셈 → 산근 저장본 칸) · 만들 때 얹기 · 이미 만든 SK 얹기(가져온 그대로인 줄만 · 바꿈 · 따로 고른 줄 그대로 · 풀면 가져온 그대로)
- 만들기: from-hopyo 가 그 파일 고름째 만듦(저장 안 한 판 고름 = 만들 때 저장) · 원본 지문 · 고른 줄 = 이음 + 재료찾기.고름 · 문서 셈 우리 값(엑셀 값 기억 없음) · 별도계상 = 품목만(줄 이음 · 단가 0 그대로)
- 문서 머리 가져온.재료(판정 · 후보 · 단서 · 처음 연결) · 가져온.지문
- 길: GET/POST hopyo-materials/{HP}(판 · [저장] · 만든 것 적용은 물음 예일 때만) · GET/POST material-picks/{지문}(SK 상세 물음)
- 화면: 단가산출 목록 위 「고를 재료」 판(후보 고름 · 찾기 · 고름 풀기 · n 블록에 적용 · 이어진 재료 접힘 · [저장]) · 저장 안 한 고름 = 떠날 때 물음 · 줄 쌓기 재료 줄 판정 배지 · 풍선말(판정 · 단서 · 후보) · 이름 단추 · [이음 고르기] 뒤 「같은 파일 n 줄」 물음
- 별도계상 설명 = 「별도계상 · 품목: 이름 규격」(엔진 이은 품목은 자재표 이름) — test_827_2 기대 글 고침(품목 이음 → 품목 이름)
- 시험 test_827_3 셋(파일 고름 블록 둘 · 다른 파일 0 · 별칭 · 마스터 그대로 · 풀기 · 다시 올림 유지 · 물음 예일 때만 · 따로 바꾼 줄 보존 · 별도계상 품목 + 단가 0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 12:51:04 +09:00

345 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""불러오기 재료 고름(PLAN 827-3 · 요구 811-2 · 812-1) — 서버 단독.
고름 = 사람이 그 파일 안 한 종(이름 · 규격 · 단위 · 단서 — `kind_of`)에 고른 우리 자재 · 별칭 아님
(마스터 · M01 · 별칭 칸 · 다른 파일에 안 씀 · 다른 것으로 바뀔 수 있음).
자리 = 개인 층 `실무자료/재료고름/<파일 지문>.json` {파일, 고름 {종: {키, 이름, 규격, 단위}}} — [끝내기] 뒤에도 남음 ·
같은 파일(지문)을 다시 올리면 그대로 보임.
판(`census`) = 그 HP 블록마다 재료 줄 판정(`자원["재료찾기"]` · 827-2)을 종으로 묶음 —
첫 판 열 때 한 번 셈 → 산근 저장본 칸 `재료판`(HP 지울 때 같이 지움).
만들기(`using` → `M02_Sangeun_FromHopyo._emit` 이 `apply`) = 고른 종 줄: 이음 = 고른 키 · `재료찾기.고름`
(문서 셈 = 우리 값 · 엑셀 값 기억 없음 · `M02_Doc_Import._attach`) · 별도계상 줄 = `고름` 만(줄 이음 · 단가 0 그대로).
문서 머리 `가져온.재료` = [{기호, 종, 원래, 판정, 방법, 키, 후보, 단서, 별도계상?, 고름?, 처음}](`entry` ·
처음 = 가져온 그대로의 연결 — 고름 풀기 · 사람이 따로 바꾼 줄 가름).
이미 만든 SK(`apply_made`) = 원본 지문이 같은 SK 문서 — 가져온 그대로인 줄만(「바꿈」 · 사람이 고른 줄 · 고친 값 뺌) →
수식 길 저장(서버가 다시 셈).
"""
from __future__ import annotations
import copy as cp
import json
from contextlib import contextmanager
from contextvars import ContextVar
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
DIRNAME = "재료고름"
CENSUS = "재료판"
KINDS = (
"규격 없음 — 고르기",
"후보 여럿",
"우리 표에 없음",
"이어짐",
) # 판 차례(`M02_San_MatFind`)
MAX_PICKS = 2000
_PICKS: ContextVar[dict | None] = ContextVar("재료고름", default=None)
def _text(v: Any) -> str:
return " ".join(str(v or "").split())
def kind_of(res: dict) -> str:
"""종 열쇠 — 가져온 자원 이름 · 규격 · 단위 + 판정 단서(같은 이름이라도 산근 머리 Φ800 · Φ1000 은 다른 종)."""
found = res.get("재료찾기") or {}
head = "|".join(_text(res.get(k)) for k in ("이름", "규격", "단위"))
return head + "|" + ";".join(_text(x) for x in found.get("단서") or [])
def _apart(res: dict) -> bool:
from M02_MasterTemplete import M02_San_Link as link
return link.apart({"이름": res.get("이름") or "", "규격": res.get("규격") or ""})
def _pick(raw: Any) -> dict | None:
if not isinstance(raw, dict) or not str(raw.get("키") or "").strip():
return None
return {k: _text(raw.get(k)) for k in ("키", "이름", "규격", "단위")}
# ── 고름 자리(개인 층) ────────────────────────────────────────────────────
def _path(folder: str | Path, fp: str) -> Path:
if not fp or not fp.isalnum() or len(fp) > 128:
raise store.StoreError(400, "파일 지문")
return hopyo_store._dir(folder) / DIRNAME / f"{fp}.json"
def read(folder: str | Path, fp: str) -> dict[str, Any]:
"""{파일, 고름 {종: 고른 것}} — 없으면 빈 것."""
try:
got = json.loads(_path(folder, fp).read_bytes())
except (OSError, ValueError):
return {"파일": "", "고름": {}}
picks = got.get("고름") if isinstance(got.get("고름"), dict) else {}
return {
"파일": str(got.get("파일") or ""),
"고름": {k: p for k, v in picks.items() if (p := _pick(v))},
}
def write(folder: str | Path, fp: str, name: str, picks: dict) -> dict[str, dict]:
"""종마다 고른 것(None = 고름 풀기)을 얹어 씀 → 지금 고름 전부."""
now = read(folder, fp)
mine = dict(now["고름"])
for k, v in (picks or {}).items():
if (p := _pick(v)) is None:
mine.pop(str(k), None)
else:
mine[str(k)] = p
if len(mine) > MAX_PICKS:
raise store.StoreError(400, "고름이 너무 많음")
path = _path(folder, fp)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, {"파일": name or now["파일"], "고름": mine})
return mine
# ── 만들기 ───────────────────────────────────────────────────────────────
@contextmanager
def using(picks: dict | None):
"""이 안에서 만드는 가져온 식 SK(안쪽 블록 포함)에 그 파일 고름을 얹음."""
token = _PICKS.set(picks or None)
try:
yield
finally:
_PICKS.reset(token)
def apply(logic: dict) -> int:
"""만들 로직 줄에 고름 얹음 → 얹은 줄 수(고름 없으면 0 · 엔진 판정 그대로)."""
picks, n = _PICKS.get(), 0
for item in (logic or {}).get("호표") or [] if picks else []:
res = item.get("자원") or {}
found = res.get("재료찾기")
if not found or not (p := picks.get(kind_of(res))):
continue
res["재료찾기"] = {**found, "고름": p}
if not _apart(res):
item["이음"] = p["키"]
n += 1
return n
def chosen(item: dict) -> dict | None:
"""그 줄 고름(별도계상 아님) — 문서 셈 = 우리 값."""
res = item.get("자원") or {}
got = (res.get("재료찾기") or {}).get("고름")
return got if got and not _apart(res) else None
def item_key(res: dict) -> str | None:
"""줄 품목 키 — 고름 › 엔진 이음."""
found = res.get("재료찾기") or {}
return (found.get("고름") or {}).get("키") or found.get("키")
def apart_note(found: dict) -> str:
"""별도계상 줄 설명 — 품목이 있으면 「별도계상 · 품목: 이름 규격」."""
from M02_MasterTemplete import M02_San_Link as link
key = (found.get("고름") or {}).get("키") or found.get("키")
if not key:
return link.APART
one = found.get("고름") or next(
(c for c in found.get("후보") or [] if c.get("키") == key), None
)
if one is None: # 엔진이 이은 품목 = 우리 자재표 줄
from M02_MasterTemplete import M02_San_MatFind as mf
one = ((mf._row(key) or {}).get("row") or {}).get("one") or {}
what = f"{_text(one.get('이름'))} {_text(one.get('규격'))}".strip() or key
return f"별도계상 · 품목: {what} · 셈은 프로젝트에서"
def entry(item: dict, sym: str, excel: Any) -> dict | None:
"""문서 머리 `가져온.재료` 한 칸(재료 찾기 판정 있는 줄만)."""
res = item.get("자원") or {}
found = res.get("재료찾기")
if not found:
return None
one = {
"기호": sym,
"종": kind_of(res),
"원래": {k: _text(res.get(k)) for k in ("이름", "규격", "단위")},
}
one |= {k: found.get(k) for k in ("판정", "방법", "키", "후보", "단서")}
if _apart(res):
one["별도계상"] = True
elif found.get("키"):
one["처음"] = {"갈래": "요소", "키": found["키"], "엑셀": excel}
else:
one["처음"] = {"갈래": "숫자", "값": excel}
return one | ({"고름": found["고름"]} if found.get("고름") else {})
# ── 판(그 파일 재료 종) ──────────────────────────────────────────────────
def census(folder: str | Path, key: str) -> list[dict]:
"""HP 블록마다 재료 줄 판정 → 종 [{종, 이름, 규격, 단위, 판정, 방법, 키, 후보, 단서, 별도계상, 블록, 줄}]."""
from M02_MasterTemplete import M02_Doc_FromNative as fn
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
hopyo_store.read(folder, key) # 없는 HP = 404
path = fh._path(folder, key)
try:
saved = json.loads(path.read_bytes())
except (OSError, ValueError) as e:
raise store.StoreError(409, fh.RELOAD) from e
if isinstance(saved.get(CENSUS), list):
return saved[CENSUS]
native = saved.get("원파일")
src = native if isinstance(native, dict) else fh._excel_of(path, saved) if saved.get("시트") else {} # fmt: skip
groups: dict[str, dict] = {}
# ponytail: 블록마다 셈 전부(기계 찾기 포함 · 70 블록 10~20 초) — 첫 판 한 번 · 느리면 재료 줄만 도는 길
for no in sorted(fn.numbers(src)) if src else []:
with using(None):
try:
out = fn.emit(src, no)
except Exception: # noqa: BLE001 — 못 옮기는 블록 = 판에서 뺌(만들기 때 그 까닭이 보임)
continue
for item in out["로직"].get("호표") or []:
res = item.get("자원") or {}
found = res.get("재료찾기")
if not found:
continue
k = kind_of(res)
g = groups.setdefault(
k,
{"종": k, **{x: _text(res.get(x)) for x in ("이름", "규격", "단위")},
**{x: found.get(x) for x in ("판정", "방법", "키", "후보", "단서")},
"별도계상": _apart(res), "블록": [], "줄": 0},
) # fmt: skip
g["줄"] += 1
if no not in g["블록"]:
g["블록"].append(no)
out_ = sorted(groups.values(), key=lambda g: (KINDS.index(g["판정"]) if g["판정"] in KINDS else 9, -len(g["블록"]), g["이름"])) # fmt: skip
atomic_write_json(path, {**saved, CENSUS: out_})
return out_
def fingerprint(folder: str | Path, key: str) -> tuple[str, str]:
hp = hopyo_store.read(folder, key)
return str(hp.get("지문") or ""), str(hp.get("파일") or "")
# ── 이미 만든 SK ─────────────────────────────────────────────────────────
def made(folder: str | Path, fp: str) -> list[str]:
"""그 층 가져온 식 SK 가운데 원본 지문이 같은 것."""
out = []
for path in sorted((Path(folder) / "sangeun").glob("SK-*.json")):
try:
doc = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
src = ((doc.get("가져온") or {}).get("원본") or {}) if isinstance(doc, dict) else {}
if fp and src.get("지문") == fp:
out.append(path.stem)
return out
def _same(a: Any, b: Any) -> bool:
"""연결이 가져온 그대로(갈래 · 키 · 숫자 값)."""
if not isinstance(a, dict) or not isinstance(b, dict) or a.get("바꿈"):
return False
if a.get("갈래") != b.get("갈래") or a.get("키") != b.get("키"):
return False
if a.get("갈래") == "숫자":
return json.dumps(a.get("값"), default=str) == json.dumps(b.get("값"), default=str) or _num(a.get("값")) == _num(b.get("값")) # fmt: skip
return a.get("갈래") != "요소" or "엑셀" in a
def _num(v: Any) -> Any:
try:
return round(float(v), 6)
except (TypeError, ValueError):
return v
def patch(doc: dict, picks: dict, kinds: set | None = None) -> int:
"""문서 `가져온.재료` 줄에 고름 얹음 · 풂 → 고친 줄 수(사람이 따로 바꾼 줄 · 「바꿈」 줄 그대로)."""
import master_doc as md # `M02_Doc_Copy` 가 scripts 를 길에 둔 뒤
by = {md.var_id(v): v for v in doc.get("변수") or []}
n = 0
for e in (doc.get("가져온") or {}).get("재료") or []:
if kinds is not None and e.get("종") not in kinds:
continue
p, was = picks.get(e.get("종")), e.get("고름")
var = by.get(e.get("기호"))
if (p or {}).get("키") == (was or {}).get("키") or var is None:
continue
link = var.get("연결") or {}
if e.get("별도계상"):
e.pop("고름", None) if p is None else e.__setitem__("고름", p)
var["설명"] = (
f"이음 없음 — {apart_note(e)} · 엑셀 단가 그대로" # `Doc_Import._attach` 꼴
)
n += 1
continue
mine = was and link.get("갈래") == "요소" and link.get("키") == was.get("키") and "엑셀" not in link and not link.get("바꿈") # fmt: skip
if not (mine or _same(link, e.get("처음"))):
continue
if p:
var["연결"] = {"갈래": "요소", "키": p["키"]}
e["고름"] = p
else:
var["연결"] = cp.deepcopy(e.get("처음"))
e.pop("고름", None)
n += 1
return n
def apply_made(fp: str, picks: dict, kinds: set | None = None, skip: tuple = ()) -> dict[str, list]:
"""지금 층(`store.root`) 그 파일 SK 에 고름 얹어 저장 → {고침, 그대로}(가져온 그대로인 줄만 바뀜)."""
from M02_MasterTemplete import M02_Doc_Sk_Path as skpath
from M02_MasterTemplete import M02_Sangeun_Doc as sd
fixed, kept = [], []
for key in made(store.root(), fp):
if key in skip:
continue
got = store.read(sd.KIND, key)
doc = cp.deepcopy(skpath.current(got["문서"])["문서"])
if not patch(doc, picks, kinds):
kept.append(key)
continue
skpath.save(key, {"문서": doc, "판": got["판"]})
fixed.append(key)
return {"고침": fixed, "그대로": kept}
def uses(fp: str, kind: str, skip: tuple = ()) -> list[str]:
"""지금 층 그 파일 SK 가운데 그 종 줄이 있는 것(SK 상세 물음 · 저장 없음)."""
from M02_MasterTemplete import M02_Sangeun_Doc as sd
out = []
for key in made(store.root(), fp):
if key in skip:
continue
doc = json.loads((store.root() / sd.KIND / f"{key}.json").read_bytes())
lines = ((doc.get("가져온") or {}).get("로직") or {}).get("호표") or []
if any(kind_of(x.get("자원") or {}) == kind for x in lines if (x.get("자원") or {}).get("재료찾기")): # fmt: skip
out.append(key)
return out
def tally(folder: str | Path, fp: str, kind: str) -> dict | None:
"""그 층 작업 중 HP(같은 지문) 판에서 그 종 {블록, 줄} 수 — 판을 아직 안 셈 · HP 없음 = None."""
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
for row in hopyo_store.rows(folder):
if row.get("지문") != fp:
continue
try:
got = json.loads(fh._path(folder, row["키"]).read_bytes()).get(CENSUS)
except (OSError, ValueError):
continue
for g in got or []:
if g.get("종") == kind:
return {"블록": len(g.get("블록") or []), "줄": g.get("줄") or 0}
return None