Files
Aislo/M02_MasterTemplete/M02_Hopyo_Match.py
T
eomsangdonandClaude Opus 5.5 e6cdcb2952 feat(일위대가실무): 338-1 맞춤 핵심 서버 2 단계 — 줄 새 상태 · 체크 제안 · 대상 목록 · 품 비교 · 호표 통째 · 셈 · 만든 · guess 목록 + 골든 시험
- 새 Line — 상태 차례(% · 호표 · 노임 · 장부 뺌 · 별칭 · 맞춤셈 판정 · 이름 근거 후보) · 체크 제안(맞음 · 별칭 규칙 셋 · 확인된 하위 호표) · 대상(입력값 출처 별칭 > 블록 > 규격 · 환산 · 몫 · 배) · 품 비교 · 직접 줄 호표 통째 · guess(대상 목록 · 낱개도 받음 · 엔진 한 번)
- Match — view(to = 만들 층 · 셈) · picks · guess 를 Line 으로 · 옛 층 기억 · confirm · 들어감 없앰 · b_order 후보에 근거 칸
- Verdict — 위 다섯 딱 맞음 먼저(조합 없는 줄) · 후보 근거 = 근거 맞춤 꼴 · 지문차례(조합 전)
- Fit — 맞춤셈 캐시 이름에 셈 판(CALC_VERSION)
- 골든 시험(느림 · 1 분 54 초) — 사람 126 1순위 118 · 다섯 안 122 · 맞음 57 틀림 0 · 다른 점 틀림 1 · 옵션까지 57 · 조합 맞음 62/83 · 새 파일 51 맞음 틀림 2 · 씨앗 끔 · 북부청 조합 전 통과
- 근거 시험(나 창) 세 곳 — 옛 match_line → b_order(새 API)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 00:00:56 +09:00

483 lines
22 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.
"""M02 일위대가 실무자료 맞춤(PLAN 324-2 · 338) — 엑셀 구성 줄 → 우리 로직 · 자재 · 입력값 · 환산.
이름 · 근거 차례(`b_order` · 338 설계 2-4 B) = 이름 같음(정규화) · 씨앗(일위대가조합.json 메모 ·
읽기만)이 1위 · 단 엑셀 인용 조항이 다른 로직을 가리키면(근거 검사 · 옛 판 글이면 그대로) 후보 ·
그 밖 = 이름 후보(글자 두 개 묶음 + 단위 + 산림 덤 · 뜻 낱말 안 겹치면 뺌 · 로직은 접지 않음) +
근거 후보(`M02_Hopyo_Basis` · 조항 같음 · 윗마디 · 직종) 합침 — 숫자 맞춤(`M02_Hopyo_Fit`)의 덤.
줄 · 호표 맞춤 응답(상태 · 체크 제안 · 대상 · 품 비교) = `M02_Hopyo_Line` · 별칭 장부 = 다 창.
규격 → 입력값 = 고르기 글이 규격(품명 괄호 글 포함)에 있으면 그 값(기계 = 고르기이름) ·
이름 붙은 수 D= H= H: B= T= L= L3= 는 입력 단위로 · 못 읽으면 빈 값(자동값) + 표시.
환산(우리 수량 = 엑셀 수량 × 배): 같은 단위 1 · 같은 성격 = 단위표 · ㎡→㎥ × T · m→㎡ × B ·
m→㎥ × B × T = 「추정」(체크 안 함) · 두 단위 로직 = 단위 입력으로 고르고 1 · 그 밖 = 사용자 입력.
M01 은 읽기만(로직 별칭 안 씀).
"""
from __future__ import annotations
import json
import re
import unicodedata
from decimal import Decimal
from functools import lru_cache
from pathlib import Path
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
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
mf = m01.cm.mf
mp = mf.mp
mm = mp.mm
SEED_NAME = "일위대가조합.json"
LOGIC_KINDS = ("단가산출", "기계", "기타")
MATERIAL_KINDS = ("자재", "별도계상")
MACH_WORDS = (
("불도우저", "불도저"),
("굴삭기", "굴착기"),
("브레이카", "브레이커"),
("로울러", "롤러"),
("크로울러", "크롤러"),
)
_SEED_TAIL = re.compile(r"\s*\(실무 수는 옛 판 값\)\s*$")
_PAREN = re.compile(r"\([^()]*\)")
_THOUSANDS = re.compile(r"(=\d{1,3}),(\d{3})(?!\d)")
_NAMED = re.compile(r"(?<![A-Za-z])([A-Za-z]\d?)\s*[=:]\s*(\d+(?:\.\d+)?)\s*(mm|cm|m)?", re.I)
# 이름 붙은 수 → 입력 이름 낱말(긴 낱말이 앞섬 · 「뒷길이」 = L3)
LETTERS = {
"D": ("관경", "직경", "구경", "지름"),
"H": ("높이",),
"B": ("폭", "너비"),
"T": ("두께",),
"L": ("거리", "길이"),
"L3": ("뒷길이",),
}
# ── 정규화 ───────────────────────────────────────────────────────────
def line_key(line: dict[str, Any]) -> str:
"""기억 열쇠 = 품명 | 규격 | 단위(정규화)."""
return "|".join(
(
mm.plain(line.get("이름")),
mm.plain_spec(line.get("규격")),
mm.plain_unit(line.get("단위")),
)
)
def tidy(name: Any) -> str:
"""정리 이름 — 괄호 글 · 기계 옛 낱말 · 빈칸."""
text = str(name or "")
for old, new in MACH_WORDS:
text = text.replace(old, new)
return mm.plain(_PAREN.sub("", text))
def words(name: Any) -> frozenset[str]:
"""뜻 낱말 — 괄호 밖 한글 덩어리 · 영문 두 글자 넘는 것
(낱글자 덩어리는 붙임 「면 목」 = 면목)."""
text = unicodedata.normalize("NFKC", str(name or "")).lower()
for old, new in MACH_WORDS:
text = text.replace(old, new)
out, single = [], ""
for piece in re.findall(r"[가-힣]+|[a-z]{2,}", _PAREN.sub(" ", text)):
if len(piece) == 1:
single += piece
continue
out += [single, piece]
single = ""
return frozenset(w for w in [*out, single] if len(w) > 1)
def near(mine: frozenset[str], theirs: frozenset[str]) -> bool:
"""뜻 낱말이 하나라도 겹침(한쪽이 다른 쪽에 듦) — 내 낱말이 없으면 거르지 않음."""
return not mine or any(a in b or b in a for a in mine for b in theirs)
def _spec_text(text: Any) -> str:
out = str(text or "")
for old, new in MACH_WORDS:
out = out.replace(old, new)
return mm.plain_spec(out).replace(",", "").replace("~", "∼")
def numbers(spec: Any) -> dict[str, tuple[Decimal, str | None]]:
"""규격의 이름 붙은 수 `{글자: (값, 단위)}` — 천 단위 쉼표(`D=1,000mm`)를 먼저 붙임."""
text = _THOUSANDS.sub(r"\1\2", unicodedata.normalize("NFKC", str(spec or "")))
return {
letter.upper(): (Decimal(value), unit.lower() if unit else None)
for letter, value, unit in _NAMED.findall(text)
}
def _in_unit(value: Decimal, have: str | None, want: Any) -> Decimal:
"""길이 값을 입력 단위로 — 한쪽이 모르는 단위면 그대로."""
scale = mp.UNITS["길이"]
want = mm.plain_unit(want) if want else None
if have in scale and want in scale:
return value * Decimal(scale[have]) / Decimal(scale[want])
return value
def _json(value: Decimal) -> int | float:
value = value.normalize()
return int(value) if value == value.to_integral_value() else float(value)
def _meters(found: tuple[Decimal, str | None] | None) -> Decimal | None:
if found is None:
return None
return _in_unit(found[0], found[1] or "m", "m")
# ── 규격 → 입력값 ─────────────────────────────────────────────────────
def spec_inputs(line: dict[str, Any], specs: list[dict[str, Any]]) -> tuple[dict, list[str]]:
"""(입력값, 못 읽은 입력 이름) — 고르기 글 · 고르기이름 · 이름 붙은 수 · 두 단위."""
raw = f"{line.get('규격') or ''} {' '.join(_PAREN.findall(str(line.get('이름'))))}"
pool = _spec_text(raw)
named = numbers(line.get("규격"))
tokens = [_spec_text(w) for w in re.split(r"[,\s/()]+", raw)]
unit = mm.plain_unit(line.get("단위"))
values, unread = {}, []
for spec in specs:
name = str(spec.get("이름") or "")
picks = spec.get("고르기") or []
labels = spec.get("고르기이름") or {}
words = [(w, letter) for letter, ws in LETTERS.items() for w in ws if w in name]
letter = max(words, key=lambda x: len(x[0]))[1] if words else None
got: Any = None
if letter in named:
value = _json(_in_unit(*named[letter], spec.get("단위")))
got = value if not picks or value in picks else None
if got is None and picks:
hits = []
for opt in picks:
label = str(labels.get(opt) or opt)
for text in (label, _PAREN.sub("", label)):
flat = _spec_text(text)
if len(flat) > 1 and (flat in pool or (unit and flat == f"{unit}당")):
hits.append((len(flat), opt))
# 규격 낱말이 고르기 글 머리(「4회」 → 「4회사용시」)
hits += [(len(w), opt) for w in tokens if len(w) > 1 and flat.startswith(w)]
got = max(hits, key=lambda x: x[0])[1] if hits else None
if got is not None:
values[name] = got
elif spec.get("자동값") in (None, ""):
unread.append(name)
return values, unread
# ── 환산 ─────────────────────────────────────────────────────────────
def factor(have: Any, want: Any, spec: Any = "") -> dict[str, Any]:
"""`{배, 갈래}` — 우리 수량 = 엑셀 수량 × 배 · 배 None = 사용자 입력."""
units = [u.strip() for u in str(want or "").split("·") if u.strip()]
plain = mm.plain_unit(have)
if len(units) > 1:
ok = plain in {mm.plain_unit(u) for u in units}
return {"배": 1 if ok else None, "갈래": "두 단위" if ok else "입력"}
if plain == mm.plain_unit(want):
return {"배": 1, "갈래": "같음"}
a, b = mp.kind_of(have), mp.kind_of(want)
if a is not None and a == b:
scale = mp.UNITS[a]
ratio = Decimal(scale[plain]) / Decimal(scale[mm.plain_unit(want)])
return {"배": _json(ratio), "갈래": "단위표"}
found = numbers(spec)
width, thick = _meters(found.get("B")), _meters(found.get("T"))
by = {("넓이", "부피"): thick, ("길이", "넓이"): width}
if (a, b) == ("길이", "부피") and width is not None and thick is not None:
by[(a, b)] = width * thick
got = by.get((a, b))
if got:
return {"배": _json(got), "갈래": "추정"}
return {"배": None, "갈래": "입력"}
def fits(have: Any, want: Any) -> bool:
"""단위가 맞음 — 같음 · 같은 성격 · 두 단위 중 하나(후보 점수 덤)."""
units = {mm.plain_unit(u) for u in str(want or "").split("·") if u.strip()}
return mm.plain_unit(have) in units or mp.same_kind(have, want)
# ── 우리 쪽 목록(한 번 읽어 둠) ─────────────────────────────────────────
def _grams(text: str) -> set[str]:
return {text[i : i + 2] for i in range(len(text) - 1)} or {text}
@lru_cache(maxsize=2)
def _targets(whole_id: int) -> dict[str, Any]:
"""로직(돈) · 자재품목 — 정규 이름 · 정리 이름 · 묶음 · 단위."""
files, whole = m01.loaded()
rows = [r for d in files.values() if d.get("그룹") == "로직" for r in d.get("줄") or []]
pills = combo_store.combo_pills({"담은로직": [{"로직": r.get("키")} for r in rows]})["물량"]
unit_of = {code: pill["단위"] for pill in pills for code in pill["로직"]} # 돈 로직만
logics, mats = [], []
for row in rows:
if str(row.get("키")) in unit_of:
name = str(row.get("이름") or "")
logics.append(
{
"로직": str(row["키"]),
"이름": name,
"단위": unit_of[str(row["키"])],
"산림": "산림" in str(row.get("구분") or ""),
"plain": mm.plain(name),
"tidy": tidy(name),
}
)
for key, row in whole.index.get(mm.MARKET, {}).items():
name = str(row.get("이름") or "")
mats.append(
{
"자재": str(key),
"이름": name,
"규격": str(row.get("규격") or ""),
"단위": str(row.get("단위") or ""),
"plain": mm.plain(name),
"spec": mm.plain_spec(row.get("규격")),
"base": (base := re.sub(r"-(사급|관급)$", "", name.strip())),
"tidy": tidy(base),
}
)
names: dict[str, dict[str, list]] = {"로직": {}, "자재": {}}
for pool, group in (("로직", logics), ("자재", mats)):
for one in group:
one["grams"] = _grams(one["tidy"])
one["words"] = words(one["base"] if "base" in one else one["이름"])
names[pool].setdefault(one["plain"], []).append(one)
return {"로직": logics, "자재": mats, "이름": names, "씨앗": _seeds()}
def targets() -> dict[str, Any]:
return _targets(id(m01.loaded()[1]))
def _seeds() -> dict[str, set[str]]:
"""씨앗 `{정규 실무 글: {로직 키}}` — 일위대가조합.json 담은로직 메모(읽기만)."""
try:
doc = json.loads((Path(m01.FOLDER) / SEED_NAME).read_bytes())
except (OSError, ValueError):
return {}
out: dict[str, set[str]] = {}
for row in doc.get("줄") or []:
for one in row.get("담은로직") or []:
memo = _SEED_TAIL.sub("", str(one.get("메모") or ""))
if memo and one.get("로직"):
out.setdefault(mm.plain(memo), set()).add(str(one["로직"]))
return out
def _public(one: dict[str, Any]) -> dict[str, Any]:
keep = ("로직", "자재", "이름", "규격", "단위")
return {k: one[k] for k in keep if k in one}
def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[str, Any]]:
"""② 이름 같음 · 씨앗 — 겹치면 여럿(부르는 쪽이 후보로 돌림)."""
name, spec = mm.plain(line.get("이름")), mm.plain_spec(line.get("규격"))
same = got["이름"][pool].get(name) or []
if pool == "자재":
return [m for m in same if m["spec"] == spec] or (same if len(same) == 1 else [])
if same:
return same
codes = got["씨앗"].get(mm.plain(f"{line.get('이름')}{line.get('규격')}")) or got["씨앗"].get(
name
)
return [x for x in got["로직"] if x["로직"] in (codes or ())]
def candidates(
line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int | None = 5
) -> list[dict[str, Any]]:
"""③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit`
(None = 모두)."""
me = tidy(line.get("이름"))
grams, unit = _grams(me), line.get("단위")
text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}")
scored, seen = [], set()
mine = words(line.get("이름"))
for one in (got or targets())[pool]:
if not grams & one["grams"] or not near(mine, one["words"]):
continue
score = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"]))
score += 1 if one["tidy"] == me else 0
score += 0.15 if fits(unit, one["단위"]) else 0
score += 0.05 if one.get("산림") else 0
score += 0.5 if one.get("spec") and one["spec"] in text else 0
scored.append((score, one))
scored.sort(key=lambda x: -x[0])
out = []
for score, one in scored:
label = one.get("로직") or (one["이름"], one.get("규격"), one["단위"]) # 로직은 접지 않음
if label in seen:
continue
seen.add(label)
out.append({**_public(one), "점수": round(score, 3)})
if len(out) == limit:
break
return out
# ── 인용 조항 · 이름 · 근거 차례(338 · 설계 2-4 B 차례) ─────────────────────
_OLD_TEXT = re.compile(r"(20(1\d|2[0-4])|1\d년|2[0-4]년|임도품셈|임도표준품셈|사방품셈)")
_TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 13-114 → 13-11-4
_GENERIC = frozenset(
"인력 기계 장비 소계 합계 노무비 재료비 경비 적용 기준 별도계상 별도 인부 보통인부 특별인부 "
"설치 해체 운반 작업 시공 사용 포함 공구손료 경장비 손료 기계경비 재료 인건비 품셈 표준품셈 "
"건설표준품셈 이하 이상".split()
)
def fix_typo(theirs: list) -> list[list[str]]:
"""엑셀 인용 조항 오타 — 「13-114」 → 「13-11-4」."""
out = []
for book, n in theirs or []:
m = _TYPO_CLAUSE.match(str(n))
out.append([book, f"{m.group(1)}-{m.group(2)}{m.group(3)}-{m.group(4)}" if m else n])
return out
def clause(theirs: list, our: dict[str, Any]) -> tuple[float, list[str], str]:
"""(점수, 맞은 우리 조항 글, 꼬리표) — 같음 1 · 윗마디 0.9(엑셀 6-2 ↔ 우리 6-2-2) · 앞 마디 0.5
·
나 창 `Basis.clause` 가 있으면 그것."""
if hasattr(hopyo_basis, "clause"):
return hopyo_basis.clause(theirs, our)
nums = [n for book, n in theirs if book == our["책"]]
same = sorted(n for n in nums if n in our["조항"])
if same:
return 1.0, [f"{our['구분']} {n}" for n in same], "조항 같음"
kids = sorted(c for c in our["조항"] for n in nums if c.startswith(n + "-"))
if kids:
return 0.9, [f"{our['구분']} {n}" for n in kids], "조항 같음"
heads = {n.rsplit("-", 1)[0] for n in nums}
near_ = sorted(n for n in our["조항"] if n.rsplit("-", 1)[0] in heads)
if near_:
return 0.5, [f"{our['구분']} {n}" for n in near_], "앞 마디 같음"
return 0.0, [], ""
def _strong_basis(line: dict[str, Any], cand: dict[str, Any]) -> bool:
if cand["근거"]["직종"]:
return True
mine = {w for w in hopyo_basis._loose_words(f"{line.get('이름')}") if w not in _GENERIC}
theirs = {w for w in hopyo_basis._loose_words(cand["이름"]) if w not in _GENERIC}
return any(a in b or b in a for a in mine for b in theirs)
def b_order(line: dict[str, Any], got: dict[str, Any] | None = None, text: Any = ()) -> dict:
"""이름 · 근거 차례 `{상태: 같음 | 후보 | 없음, top: [로직 5], 합침: [로직 15], 후보: [5],
근거: {로직: 근거}}` — 이름 후보 + 근거 후보 합침(접힘 없음) · 「같음」도 근거 검사(인용 조항이
다른 로직을 가리키면 후보) · 옛 판 글이면 같음 그대로 · 씨앗은 이름 같음 자리."""
got = got or targets()
basis_ = dict(line.get("근거") or {})
if basis_.get("출처"):
line = {**line, "근거": {**basis_, "출처": fix_typo(basis_["출처"])}}
basis = hopyo_basis.candidates(line, got)
named = candidates(line, "로직", got, None)
merged_c = [c for c in hopyo_basis.merge(basis, named) if c.get("로직")]
merged = [c["로직"] for c in merged_c]
why = {c["로직"]: c["근거"] for c in merged_c if c.get("근거")}
by = {c["로직"]: c for c in merged_c}
pub = {x["로직"]: x for x in got["로직"]}
def done(state: str, top: list[str]) -> dict:
cands = [by.get(k) or {**_public(pub[k]), "점수": None} for k in top[:5] if k in pub]
return {"상태": state, "top": top[:5], "합침": merged[:15], "후보": cands, "근거": why}
found = _exact(line, "로직", got)
if len(found) == 1:
tgt = found[0]["로직"]
theirs = (line.get("근거") or {}).get("출처") or []
our = hopyo_basis.ours().get(tgt)
if _OLD_TEXT.search(" ".join(str(t) for t in list(text)[:5])) or not theirs:
return done("같음", [tgt])
if our and clause(theirs, our)[0] >= 0.9:
return done("같음", [tgt])
strong = [c for c in basis if c["근거"]["까닭"].startswith(("조항 같음", "윗마디 같음"))]
strong = [c for c in strong if _strong_basis(line, c)]
if not strong:
return done("같음", [tgt])
top = list(dict.fromkeys([strong[0]["로직"], tgt] + [c["로직"] for c in strong[1:]]))
return done("후보", top)
top = [x["로직"] for x in found]
rest = [c for c in merged_c if c["로직"] not in top]
first = next((c.get("로직") for c in named if c.get("로직") not in top), None)
top += [c["로직"] for c in hopyo_basis.pick(rest, first if basis else None, 5 - len(top))]
return done("후보" if top else "없음", top)
# ── 줄 · 호표 맞춤(338 · 설계 3-3 · 3-4 — 몸은 `M02_Hopyo_Line`) ─────────────────
def _logic_specs(code: str) -> list[dict[str, Any]]:
got = labor_inputs.logic_inputs([code])["로직"]
return got[0]["입력"] if got else []
def _line_mod():
from M02_MasterTemplete import M02_Hopyo_Line as line_mod # 줄 맞춤이 이 파일을 부름
return line_mod
def units_of(doc: dict[str, Any]) -> dict[int, str]:
return {int(h["번"]): str(h.get("단위") or "") for h in doc.get("호표") or []}
def view(folder: Path, key: str, no: int | None = None, to: Path | None = None) -> dict[str, Any]:
"""실무자료 보기 — `no` 없으면 호표마다 [체크 제안 수, 줄 수] · 있으면 그 호표 줄마다 맞춤(3-3)
·
`to` = 만들 층(장부 · 확인된 사전조합 · 없으면 파일 층) · 맞춤셈이 없으면 뒤 셈을 넣음."""
lines = _line_mod()
ctx = lines.Ctx(folder, key, to)
doc = ctx.doc
if no is None:
hopyos = [lines.hopyo_view(h, ctx, False) for h in doc.get("호표") or []]
keep = ("번", "이름", "규격", "단위", "금액", "맞춤", "행")
brief = [{k: h.get(k) for k in keep if k in h} for h in hopyos]
ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)]
head = {**hopyo_store.brief(doc), "검산": doc.get("검산"), "셈": ctx.calc}
return {**head, "호표": brief, "맞춤": ready}
hopyo = ctx.units.get(int(no))
if hopyo is None:
raise store.StoreError(404, f"없는 호표 「제{no}호표」")
return {**hopyo_store.brief(doc), "호표": lines.hopyo_view(hopyo, ctx)}
def guess(
folder: Path, key: str, no: int, at: int, target: Any, to: Path | None = None
) -> dict[str, Any]:
"""미리 셈(3-4 · 저장 안 함) — 대상 목록(낱개도 받음) → {대상, 품, 글}."""
return _line_mod().guess(folder, key, no, at, target, to)
def picks(folder: Path) -> list[dict[str, Any]]:
"""[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 실무자료 호표마다 맞춤 [체크 제안 수, 줄 수]
(장부 · 확인된 사전조합 = 이 층)."""
lines, out = _line_mod(), []
for one in chain(folder):
layer, owner = refs.where(one)
for row in hopyo_store.rows(one):
ctx = lines.Ctx(one, row["키"], folder)
for hopyo in ctx.doc.get("호표") or []:
ready = lines.hopyo_view(hopyo, ctx, False)["맞춤"]
head = {"층": layer, "주인": owner, "키": row["키"], "파일": row["파일"]}
keep = ("번", "이름", "규격", "단위")
out.append({**head, **{k: hopyo.get(k) for k in keep}, "맞춤": ready})
return out
def chain(folder: 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