Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_1

This commit is contained in:
2026-10-05 19:04:05 +09:00
12 changed files with 966 additions and 53 deletions
+39 -3
View File
@@ -244,10 +244,15 @@ def load_prices(prices: dict[str, Any]) -> Any:
# ── 계산(25-2e) ─────────────────────────────────────────────────────
def _logic_keys(labor: dict[str, Any]) -> list[str]:
"""담은 로직 키 — 참조 줄(로직 없음 · 50-3)은 뺌."""
return [key for key in mcb.logic_keys(labor.get("줄") or {}) if key]
def missing_inputs(snapshot: Any, labor: dict[str, Any], values: dict[str, Any]) -> list[dict]:
"""담은 로직마다 값이 없는(null · 안 적음) 입력 — `{로직, 입력}`."""
out = []
for key in mcb.logic_keys(labor.get("줄") or {}):
for key in _logic_keys(labor):
try:
row = snapshot.logic(key)
except mf.FormulaError:
@@ -277,7 +282,7 @@ def calc(
blocked = {gap["로직"] for gap in gaps}
sums = dict.fromkeys(mf.COST_ITEMS, Decimal(0))
lines, stuck = [], []
for at, key in enumerate(mcb.logic_keys(labor.get("줄") or {}), start=1):
for at, key in enumerate(_logic_keys(labor), start=1):
head: dict[str, Any] = {"차례": at, "로직": key}
if key in blocked:
names = [gap["입력"] for gap in gaps if gap["로직"] == key]
@@ -306,10 +311,37 @@ def calc(
"계": got["계"],
}
)
for line in _ref_lines(snapshot, labor, len(lines)): # 참조 줄(50-3) — 굳힌 사본으로
if line["ok"]:
for name in mf.COST_ITEMS:
sums[name] += line[name]
else:
stuck.append(line["참조"]["키"])
lines.append(line)
out = {"줄": lines, **sums, "계": sum(sums.values()), "빠진입력": gaps, "멈춤": stuck}
return to_json({**out, **_reflected(snapshot, labor, lines, area)})
def _ref_lines(snapshot: Any, labor: dict[str, Any], start: int) -> list[dict]:
"""참조 줄 — 기준 = 굳힌 사본(`참조사본`)을 복사본 단가로 펼친 1 단위 값
(입력값은 승격 때 고정)."""
from M02_MasterTemplete import M02_Labor_Ref as refs
copies = labor.get("참조사본") or {}
expand = refs.Expand(snapshot, lambda ref: copies.get(refs.ref_id(ref)))
out = []
for at, one in enumerate((labor.get("줄") or {}).get("담은로직") or [], start=start + 1):
if not (isinstance(one, dict) and isinstance(one.get("참조"), dict)):
continue
got = expand(one["참조"])
head = {"차례": at, "참조": one["참조"], "이름": got.get("이름"), "분모": got.get("단위")}
if got.get("기준") is None:
out.append({**head, "ok": False, "까닭": got.get("까닭")})
continue
out.append({**head, "ok": True, "갈래": "돈", **got["기준"]})
return out
def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: Any) -> dict:
"""줄마다 수량 · 반영 + 자재 + 기준합 · 반영합 — 기준은 위 줄 그대로 · 알약은 가져온 때 것."""
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
@@ -318,8 +350,12 @@ def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: An
pill_of = {code: pill for pill in labor.get("알약") or [] for code in pill.get("로직") or []}
base = []
for line in lines:
pill = pill_of.get(line["로직"]) or {}
money = {k: line[k] for k in (*mf.COST_ITEMS, "계")} if line.get("갈래") == "돈" else None
if "참조" in line: # 알약 키 = ref_id · 배 1(50-3)
head = {"참조": line["참조"], "알약": labor_doc.line_id(line), "배": 1}
base.append({**head, "분모": line.get("분모"), "기준": money})
continue
pill = pill_of.get(line["로직"]) or {}
head = {"로직": line["로직"], "알약": pill.get("키"), "배": pill.get("배") or 1}
base.append({**head, "분모": pill.get("단위"), "기준": money})
mats = []
+10 -3
View File
@@ -3,7 +3,8 @@
모으는 것 = `{파일 이름: 내용}`:
`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA ·
`일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄)
+ 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) ·
+ 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) +
참조사본(단가승격 · 사전조합을 값으로 굳힘) ·
`단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT)
`원본판` = 모은 내용의 해시 — 지금 층 · 마스터에서 다시 모은 판과 대어 「마스터가 바뀜」 을 앎.
프로젝트가 고치는 값(`입력값.json` · `고른자재.json`)은 원본이 아니라 여기서 안 모음.
@@ -89,10 +90,13 @@ def labor_rows(ua_key: str) -> dict[str, Any] | None:
def labor_doc_rows(doc: dict[str, Any]) -> dict[str, Any]:
"""일위대가 문서(39-1a) + 담은 로직 줄 + 알약(`M02_Labor_Doc.pills` · 가져온 때 그대로 —
복사본 계산이 마스터를 안 읽게)."""
복사본 계산이 마스터를 안 읽게) + 참조사본 `{ref_id: 문서}`(50-3 · 원본이 바뀌어도 그대로)."""
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
from M02_MasterTemplete import M02_Labor_Ref as refs
return {**logic_rows(doc), "알약": labor_doc.pills(doc)}
rows = {**logic_rows(doc), "알약": labor_doc.pills(doc)}
copies = refs.frozen(doc)
return {**rows, "참조사본": copies} if copies else rows
def logic_rows(row: dict[str, Any]) -> dict[str, Any]:
@@ -142,7 +146,10 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None
if labor is not None:
files[LABOR_FILE] = labor
from M02_MasterTemplete import M02_Labor_Ref as refs
logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])]
logic_keys += refs.frozen_logics((labor or {}).get("참조사본") or {}) # 굳힌 단가승격 로직
materials = [str(m.get("코드")) for m in ((labor or {}).get("줄") or {}).get("자재") or []]
wanted = logic_keys + [code for code in materials if code]
files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {}
+73 -23
View File
@@ -1,12 +1,15 @@
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량}],
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량}
| {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은
반영 없음 · 자재 기준 단가 = `master_material.element` 기본값 · 자재 줄에 값 열 다섯(`가격`) ·
기준단가가 된 칸 이름(`기준칸`)도 실음(43-3).
참조 줄(단가승격 · 사전조합 · 50-3) = 기준이 가리킨 문서 1 단위 값(펼침 `M02_Labor_Ref.Expand`) ·
분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만.
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
"""
@@ -19,6 +22,7 @@ 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_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
mf = m01.cm.mf
@@ -67,11 +71,27 @@ def clean(doc: Any) -> dict[str, Any]:
if not isinstance(doc, dict):
raise _bad("일위대가 문서가 묶음 아님")
whole = m01.loaded()[1]
lines, seen = [], set()
lines, seen, here = [], set(), None
for at, one in enumerate(doc.get("담은로직") or [], start=1):
where = f"단가산출 로직 {at}째"
if not isinstance(one, dict):
raise _bad(f"{where} · 묶음 아님")
if one.get("참조") is not None and not one.get("로직"):
here = here or refs.here()
ref = refs.check_ref(one["참조"], where, here)
if refs.ref_id(ref) in seen:
raise _bad(f"{where} · 같은 참조를 두 번 담음 「{ref['키']}」")
seen.add(refs.ref_id(ref))
memo, alias = one.get("메모"), _alias(one.get("별칭"), where)
lines.append(
{
"참조": ref,
"별칭": alias,
"메모": str(memo) if memo else None,
"수량": _qty(one.get("수량"), where),
}
)
continue
code = str(one.get("로직") or "")
try:
logic = whole.logic(code)
@@ -84,16 +104,11 @@ def clean(doc: Any) -> dict[str, Any]:
given = one.get("입력값") or {}
if not isinstance(given, dict):
raise _bad(f"{where} · 입력값이 묶음 아님")
memo, alias = one.get("메모"), one.get("별칭")
if alias is not None and not isinstance(alias, str):
raise _bad(f"{where} · 별칭은 글")
alias = (alias or "").strip()
if len(alias) > ALIAS_MAX:
raise _bad(f"{where} · 별칭은 {ALIAS_MAX}자까지")
memo, alias = one.get("메모"), _alias(one.get("별칭"), where)
lines.append(
{
"로직": code,
"별칭": alias or None,
"별칭": alias,
"메모": str(memo) if memo else None,
"입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")},
"수량": _qty(one.get("수량"), where),
@@ -133,6 +148,15 @@ def clean(doc: Any) -> dict[str, Any]:
return out
def _alias(alias: Any, where: str) -> str | None:
if alias is not None and not isinstance(alias, str):
raise _bad(f"{where} · 별칭은 글")
alias = (alias or "").strip()
if len(alias) > ALIAS_MAX:
raise _bad(f"{where} · 별칭은 {ALIAS_MAX}자까지")
return alias or None
def from_combo(key: str) -> dict[str, Any]:
"""옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 —
담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키).
@@ -161,8 +185,10 @@ def material_key(code: str) -> str:
def pills(doc: dict) -> list[dict[str, Any]]:
"""면적산출 알약 — 로직 물량 알약(`combo_pills` 그대로) + 자재 알약 「자재|MT키」."""
"""면적산출 알약 — 로직 물량 알약(`combo_pills` 그대로) + 참조 알약(`refs.pill`) +
자재 알약 「자재|MT키」."""
logic = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
logic += [refs.pill(one["참조"]) for one in doc.get("담은로직") or [] if _is_ref(one)]
mats = [
{
"키": material_key(m["코드"]),
@@ -230,16 +256,25 @@ def _pick(hand: Any, key: Any, given: dict[str, Decimal]) -> tuple[Decimal | Non
return None, "없음"
def _is_ref(one: Any) -> bool:
return isinstance(one, dict) and isinstance(one.get("참조"), dict)
def line_id(one: dict) -> Any:
"""줄 가름 — 로직 키 · 참조 줄은 `ref_id`(짝 `Labor_Calc.ts` `lineId`)."""
return refs.ref_id(one["참조"]) if _is_ref(one) else one.get("로직")
def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]:
"""기준이 선 계산 답에 수량 · 반영 · 합을 채움 — 수량만 바뀌면 이것만 다시
(짝: `Labor_Calc.ts` `settle` · 자재 = 재료비)."""
have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None}
hands = {one.get("로직"): one.get("수량") for one in doc.get("담은로직") or []}
hands = {line_id(one): one.get("수량") for one in doc.get("담은로직") or []}
mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []}
zero = dict.fromkeys((*COSTS, "계"), Decimal(0))
lines, mats, bases, reflects = [], [], [], []
for line in result.get("줄") or []:
qty, source = _pick(hands.get(line.get("로직")), line.get("알약"), have)
qty, source = _pick(hands.get(line_id(line)), line.get("알약"), have)
base = _money(line.get("기준"))
done = reflect(base, qty, _number(line.get("배")) or Decimal(1)) if base else None
bases.append(base)
@@ -277,6 +312,7 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
"""`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) —
줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음)."""
whole = m01.loaded()[1]
expand = refs.Expand(whole)
by_logic = {
code: pill
for pill in combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
@@ -284,6 +320,9 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
}
lines = []
for one in doc.get("담은로직") or []:
if _is_ref(one):
lines.append(ref_line(one["참조"], expand(one["참조"])))
continue
code = one["로직"]
pill = by_logic.get(code) or {}
line = {
@@ -292,17 +331,7 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
"분모": pill.get("단위"),
"배": pill.get("배") or 1,
}
try:
logic = whole.logic(code)
auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None}
got = mf.run(whole, code, {**auto, **m01._dec(one.get("입력값") or {})})
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
lines.append({**line, "기준": None, "까닭": str(e) or type(e).__name__})
continue
if "결과" in got: # 돈 아닌 로직 — 반영 없음
lines.append({**line, "결과": got["결과"], "기준": None, "까닭": None})
continue
lines.append({**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None})
lines.append({**line, **run_line(whole, code, one.get("입력값"))})
mats = [
{
"코드": one["코드"],
@@ -315,6 +344,27 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
return settle(_json({"줄": lines, "자재": mats}), doc, have)
def run_line(whole: Any, code: str, given: Any) -> dict[str, Any]:
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값) — `{기준, 까닭}` ·
돈 아닌 로직은 `{결과, 기준: None}`(반영 없음)."""
try:
logic = whole.logic(code)
auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None}
got = mf.run(whole, code, {**auto, **m01._dec(given or {})})
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return {"기준": None, "까닭": str(e) or type(e).__name__}
if "결과" in got:
return {"결과": got["결과"], "기준": None, "까닭": None}
return {"기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None}
def ref_line(ref: dict, got: dict[str, Any]) -> dict[str, Any]:
"""참조 줄 계산 답 — 분모 = 가리킨 문서 단위 · 배 1 · `안` = 사전조합 안 줄(한 층)."""
line = {"참조": ref, "이름": got.get("이름"), "알약": refs.ref_id(ref), "분모": got.get("단위")}
line.update({"배": 1, "기준": got.get("기준"), "까닭": got.get("까닭")})
return {**line, "안": got["안"]} if "안" in got else line
def _json(value: Any) -> Any:
"""Decimal → JSON 수(정수면 int)."""
if isinstance(value, Decimal):
+407
View File
@@ -0,0 +1,407 @@
"""M02 일위대가 조합 서고 — 단가승격 · 사전조합 · 참조(PLAN 50-3 · 조사 갈래 A).
자리 = 층 양식 길 새 종류 둘(`M02_Template_Layers.LIBRARY_KINDS`) — 파일 이름 = 키:
promote `{층}/promote/SP-######.json` 단가승격
`{종류:"단가승격", 키, 이름, 단위, 배, 본뜬, 담은로직:[{로직, 입력값}]}` —
줄 하나 · 입력값은 승격 때 고정 · 값 = 로직 기준 ÷ 배(1 단위 값 · `배` 는 저장 때)
precombo `{층}/precombo/SC-######.json` 사전조합
`{종류:"사전조합", 키, 이름, 단위, 본뜬, 담은로직:[{참조, 수량:{손}, 메모}]}` —
승격 · 사전조합만 · 수량은 손(기본 1) · 값 = Σ 안 줄 값 × 수량
참조 = `{층, 주인, 키}`(마스터 주인 null · 회사 `C` · 개인 `C/U`) — 종류는 키 머리(SP · SC) ·
위 층만 가리킴(개인 → 그 개인 · 그 회사 · 마스터 / 회사 → 그 회사 · 마스터 / 마스터 → 마스터) ·
같은 참조 두 번 · 순환(422)은 저장 때 막음 · 계산 때도 깊이 상한(`DEPTH`)에서 멈추고 까닭.
지우기 = 쓰는 곳(모든 층 일위대가 · 사전조합)이 있으면 409 · 층 사이 복사 = 대상 층이 못 읽는
참조는 그 문서도 같이 복사(새 키 · `본뜬`) · 프로젝트 = 참조사본으로 굳힘(`frozen`).
"""
from __future__ import annotations
import json
import re
from collections.abc import Callable
from decimal import Decimal
from pathlib import Path
from typing import Any
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
PREFIX = {"promote": "SP", "precombo": "SC"}
TITLE = {"promote": "단가승격", "precombo": "사전조합"}
DEPTH = 8 # 참조 깊이 상한(저장 · 계산 둘 다)
REF_HEAD = "참조|"
_KEY = re.compile(r"^(SP|SC)-(\d{6})$")
_OWNER = {"company": re.compile(r"^\d+$"), "personal": re.compile(r"^\d+/\d+$")}
Ref = dict[str, Any]
Here = tuple[str, str | None]
def _bad(text: str, status: int = 400) -> store.StoreError:
return store.StoreError(status, text)
def kind_of(key: str) -> str:
return "promote" if str(key).startswith("SP") else "precombo"
def ref_id(ref: Ref) -> str:
"""참조 한 줄의 이름표 — 알약 키 · 줄 가름(짝 `Labor_Calc.ts` `lineId`)."""
return f"{REF_HEAD}{ref.get('층')}|{ref.get('주인') or ''}|{ref.get('키')}"
# ── 자리 ──────────────────────────────────────────────────────────────
def where(folder: str | Path) -> Here:
"""층 폴더 → (층, 주인) — 저장소 밖 · 프로젝트는 ("project", None)."""
path = Path(folder).resolve()
if path in (Path(store.FOLDER).resolve(), layers.system_dir().resolve()):
return "system", None
try:
parts = path.relative_to(layers.storage_root()).parts
except ValueError:
return "project", None
if len(parts) == 2 and parts[1] == layers.TEMPLATES_DIRNAME:
return "company", parts[0]
if len(parts) == 3 and parts[2] == layers.TEMPLATES_DIRNAME:
return "personal", f"{parts[0]}/{parts[1]}"
return "project", None
def here() -> Here:
return where(store.root())
def folder_of(layer: str, owner: str | None) -> Path:
if layer == "system":
return Path(store.FOLDER)
if layer == "company":
return layers.company_dir(owner or "")
company, user = str(owner).split("/", 1)
return layers.personal_dir(company, user)
def ref_path(ref: Ref) -> Path:
return folder_of(ref["층"], ref.get("주인")) / kind_of(ref["키"]) / f"{ref['키']}.json"
def read_ref(ref: Ref) -> dict[str, Any] | None:
try:
got = json.loads(ref_path(ref).read_bytes())
except (OSError, ValueError):
return None
return got if isinstance(got, dict) else None
def readable(at: Here, ref: Ref) -> bool:
"""`at` 층이 `ref` 를 가리켜도 되나 — 위 층만(프로젝트는 아무것도 · 굳힌 사본만 씀)."""
layer, owner = at
if layer == "project":
return False
if ref["층"] == "system":
return True
if ref["층"] == "company":
return layer in ("company", "personal") and str(owner).split("/")[0] == ref["주인"]
return layer == "personal" and owner == ref["주인"]
def check_ref(raw: Any, at: str, here_: Here) -> Ref:
"""참조 꼴 · 위 층만 · 있는 문서 — 아니면 400."""
if not isinstance(raw, dict):
raise _bad(f"{at} · 참조는 {{층, 주인, 키}}")
layer, owner, key = raw.get("층"), raw.get("주인"), str(raw.get("키") or "")
if layer == "system":
owner = None
elif layer not in _OWNER or not _OWNER[layer].match(str(owner or "")):
raise _bad(f"{at} · 참조 층 · 주인 꼴이 틀림")
if not _KEY.match(key):
raise _bad(f"{at} · 참조 키는 SP-###### · SC-######")
ref = {"층": layer, "주인": None if owner is None else str(owner), "키": key}
if not readable(here_, ref):
raise _bad(f"{at} · 위 층만 가리킴 — 「{key}」({layer} {owner or ''})")
if read_ref(ref) is None:
raise _bad(f"{at} · 없는 참조 「{key}」")
return ref
# ── 서고 문서 정리 ─────────────────────────────────────────────────────
def check_key(kind: str, name: str) -> None:
found = _KEY.match(name)
if not found or found.group(1) != PREFIX[kind]:
raise _bad(f"{TITLE[kind]} 키는 {PREFIX[kind]}-######")
def clean_doc(kind: str, doc: Any) -> dict[str, Any]:
"""서고 문서를 서버가 받는 꼴로 — `doc["키"]` = 파일 이름(부르는 쪽이 넣음)."""
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Labor_Doc as labor
if not isinstance(doc, dict):
raise _bad("서고 문서가 묶음 아님")
key = str(doc.get("키") or "")
check_key(kind, key)
name, unit = str(doc.get("이름") or "").strip(), str(doc.get("단위") or "").strip()
out: dict[str, Any] = {"종류": TITLE[kind], "키": key}
if kind == "promote":
lines = doc.get("담은로직") or []
if len(lines) != 1 or not isinstance(lines[0], dict) or not lines[0].get("로직"):
raise _bad("단가승격은 단가산출 로직 한 줄")
line = labor.clean({"담은로직": lines})["담은로직"][0]
pill = (combo_store.combo_pills({"담은로직": [line]})["물량"] or [{}])[0]
out.update(
이름=name or pill.get("이름") or line["로직"],
단위=unit or pill.get("단위") or None,
배=pill.get("배") or 1, # ponytail: 저장 때 적음 · 마스터 결과단위가 바뀌면 다시 저장
담은로직=[{"로직": line["로직"], "입력값": line["입력값"]}],
)
else:
if not name:
raise _bad("사전조합 이름이 빔")
out.update(이름=name, 단위=unit or None, 담은로직=_combo_lines(doc, key))
out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None
return out
def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]:
from M02_MasterTemplete import M02_Labor_Doc as labor
at_, seen, lines = here(), set(), []
for n, one in enumerate(doc.get("담은로직") or [], start=1):
at = f"사전조합 {n}째"
if not isinstance(one, dict) or one.get("로직") or one.get("참조") is None:
raise _bad(f"{at} · 사전조합은 단가승격 · 사전조합만 담음")
ref = check_ref(one["참조"], at, at_)
if ref_id(ref) in seen:
raise _bad(f"{at} · 같은 참조를 두 번 담음 「{ref['키']}」")
seen.add(ref_id(ref))
memo = one.get("메모")
lines.append(
{
"참조": ref,
"수량": labor._qty(one.get("수량"), at) or {"손": 1},
"메모": str(memo) if memo else None,
}
)
me = {"층": at_[0], "주인": at_[1], "키": key}
for one in lines:
_no_loop(one["참조"], [me, one["참조"]])
return lines
def _no_loop(ref: Ref, trail: list[Ref]) -> None:
"""저장 때 순환 · 깊이 — 아래로 따라가 처음 문서를 다시 만나면 422 「A → B → A」."""
if len(trail) > DEPTH:
raise _bad(f"참조가 {DEPTH}단을 넘음 — " + " → ".join(r["키"] for r in trail), 422)
for one in (read_ref(ref) or {}).get("담은로직") or []:
child = one.get("참조") if isinstance(one, dict) else None
if not isinstance(child, dict) or not child.get("키"):
continue
if ref_id(child) == ref_id(trail[0]):
raise _bad("순환 — " + " → ".join(r["키"] for r in [*trail, child]), 422)
_no_loop(child, [*trail, child])
def next_key(kind: str) -> str:
"""지금 층 그 종류의 다음 키(가장 큰 번호 + 1)."""
folder = store.root() / kind
have = [int(m.group(2)) for p in folder.glob("*.json") if (m := _KEY.match(p.stem))]
return f"{PREFIX[kind]}-{max(have, default=0) + 1:06d}"
def create(kind: str, doc: dict[str, Any]) -> dict[str, Any]:
"""지금 층에 새 키로 씀 → `store.write` 정보(+ 키) — 그새 남이 같은 키를 잡으면 다음 키."""
for _ in range(5):
key = next_key(kind)
try:
return store.write(kind, key, "", {**doc, "키": key})
except store.StoreError as e:
if e.status != 409 or not isinstance(e.detail, dict):
raise
raise _bad("새 키를 못 잡음 — 다시 누름", 409)
def summary(folder: Path, kind: str, key: str) -> dict[str, Any]:
"""층 목록 한 줄에 붙일 것 — `{제목, 단위}`."""
try:
doc = json.loads((Path(folder) / kind / f"{key}.json").read_bytes())
except (OSError, ValueError):
return {}
return {"제목": doc.get("이름"), "단위": doc.get("단위")}
# ── 쓰는 곳 · 지우기 ──────────────────────────────────────────────────
def users(ref: Ref) -> list[str]:
"""`ref` 를 담은 일위대가 · 사전조합 — 「어디 이름」 목록(모든 층 훑음 · 프로젝트 뺌)."""
rid, out = ref_id(ref), []
for kind, what in (("labor", "일위대가"), ("precombo", "사전조합")):
for at, path in layers.layer_files(kind):
try:
doc = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
lines = doc.get("담은로직") if isinstance(doc, dict) else None
if any(
isinstance(one, dict)
and isinstance(one.get("참조"), dict)
and ref_id(one["참조"]) == rid
for one in lines or []
):
name = doc.get("일위대가번") or doc.get("이름") or path.stem
out.append(f"{what} {at} 「{name}」")
return out
def refuse_used(folder: Path, kind: str, key: str) -> None:
"""쓰는 곳이 있으면 409 + 이름(서고 종류만)."""
if kind not in PREFIX:
return
layer, owner = where(folder)
found = users({"층": layer, "주인": owner, "키": key})
if found:
raise _bad(f"쓰는 곳이 있어 못 지움 「{key}」 — " + " · ".join(found), 409)
# ── 층 사이 복사 ──────────────────────────────────────────────────────
def carry(doc: dict[str, Any], source: Here) -> dict[str, Any]:
"""복사(원본 층 `source` → 지금 층) — 지금 층이 못 읽는 참조는 그 문서를 지금 층에 새 키로
같이 복사(`본뜬` = 원래 참조) · 참조를 새 키로 바꿈. 원본 층 읽기 권한은 부르는 쪽 몫."""
target, done = here(), {}
def move(ref: Ref, depth: int) -> Ref:
if readable(target, ref):
return ref
if ref_id(ref) in done:
return done[ref_id(ref)]
if depth > DEPTH or not readable(source, ref):
raise _bad(f"복사할 수 없는 참조 「{ref.get('키')}」")
got = read_ref(ref)
if got is None:
raise _bad(f"없는 참조 「{ref['키']}」")
body = {**got, "담은로직": _moved(got.get("담은로직"), move, depth + 1), "본뜬": ref}
made = create(kind_of(ref["키"]), body)
done[ref_id(ref)] = {"층": target[0], "주인": target[1], "키": made["이름"]}
return done[ref_id(ref)]
return {**doc, "담은로직": _moved(doc.get("담은로직"), move, 1)}
def _moved(lines: Any, move: Callable[[Ref, int], Ref], depth: int) -> list[Any]:
return [
{**one, "참조": move(one["참조"], depth)}
if isinstance(one, dict) and isinstance(one.get("참조"), dict)
else one
for one in lines or []
]
# ── 펼침(계산) ────────────────────────────────────────────────────────
class Expand:
"""참조 → `{이름, 단위, 기준(1 단위), 까닭, 안?}` — 한 번 셈 안에서 키별 캐시 ·
깊이 상한 · 순환은 까닭(통째로 멈추지 않음). `read` = 참조 → 문서(프로젝트는 굳힌 사본)."""
def __init__(self, whole: Any, read: Callable[[Ref], dict | None] = read_ref) -> None:
self.whole, self.read, self.memo = whole, read, {}
def __call__(self, ref: Ref, trail: tuple[str, ...] = ()) -> dict[str, Any]:
rid = ref_id(ref)
if rid in trail:
keys = [t.rsplit("|", 1)[-1] for t in (*trail, rid)]
return self._stop(ref, "순환 — " + " → ".join(keys))
if len(trail) >= DEPTH:
return self._stop(ref, f"참조가 {DEPTH}단을 넘음")
if rid not in self.memo:
doc = self.read(ref)
if not isinstance(doc, dict):
return self._stop(ref, f"없는 참조 「{ref['키']}」")
if kind_of(ref["키"]) == "promote":
self.memo[rid] = self._promote(doc)
else:
self.memo[rid] = self._combo(doc, (*trail, rid))
return self.memo[rid]
@staticmethod
def _stop(ref: Ref, why: str) -> dict[str, Any]:
return {"이름": ref["키"], "단위": None, "기준": None, "까닭": why}
def _promote(self, doc: dict) -> dict[str, Any]:
from M02_MasterTemplete import M02_Labor_Doc as labor
line = (doc.get("담은로직") or [{}])[0]
got = labor.run_line(self.whole, str(line.get("로직") or ""), line.get("입력값"))
times = labor._number(doc.get("배")) or Decimal(1)
base = got.get("기준")
why = got.get("까닭") or (None if base else "돈 아닌 로직")
return {
"이름": doc.get("이름"),
"단위": doc.get("단위"),
"로직": line.get("로직"),
"기준": {k: Decimal(str(v)) / times for k, v in base.items()} if base else None,
"까닭": why,
}
def _combo(self, doc: dict, trail: tuple[str, ...]) -> dict[str, Any]:
from M02_MasterTemplete import M02_Labor_Doc as labor
inner, rows, why = [], [], None
for one in doc.get("담은로직") or []:
ref = one.get("참조") if isinstance(one, dict) else None
if not isinstance(ref, dict):
continue
sub = self(ref, trail)
qty = labor._number((one.get("수량") or {}).get("손"))
qty = Decimal(1) if qty is None else qty
keep = ("이름", "단위", "기준", "까닭")
inner.append({"참조": ref, "수량": qty, **{k: sub.get(k) for k in keep}})
if sub.get("기준") is None:
why = why or f"「{sub.get('이름')}」 {sub.get('까닭')}"
continue
rows.append({k: v * qty for k, v in sub["기준"].items()})
return {
"이름": doc.get("이름"),
"단위": doc.get("단위"),
"기준": None if why else labor._sum(rows),
"까닭": why,
"안": inner,
}
def pill(ref: Ref, read: Callable[[Ref], dict | None] = read_ref) -> dict[str, Any]:
"""참조 줄 면적산출 알약 — 키 = `ref_id` · 이름 · 단위 = 가리킨 문서 것."""
doc = read(ref) or {}
return {
"키": ref_id(ref),
"이름": doc.get("이름") or ref["키"],
"단위": doc.get("단위") or "",
"로직": [],
"참조": ref,
}
# ── 프로젝트 굳힘 ─────────────────────────────────────────────────────
def frozen(doc: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""일위대가 문서가 닿는 참조 문서 전부 `{ref_id: 문서}` — 가져올 때 값으로 굳힘
(원본이 바뀌어도 프로젝트는 그대로)."""
out: dict[str, dict[str, Any]] = {}
def walk(lines: Any, depth: int) -> None:
for one in lines or []:
ref = one.get("참조") if isinstance(one, dict) else None
if not isinstance(ref, dict) or ref_id(ref) in out or depth > DEPTH:
continue
got = read_ref(ref)
if got is not None:
out[ref_id(ref)] = got
walk(got.get("담은로직"), depth + 1)
walk(doc.get("담은로직"), 1)
return out
def frozen_logics(copies: dict[str, Any]) -> list[str]:
"""굳힌 단가승격이 담은 로직 키 — 프로젝트 단가 묶음에 넣음."""
return [
str(line["로직"])
for rid, doc in copies.items()
if kind_of(rid.rsplit("|", 1)[-1]) == "promote"
for line in (doc.get("담은로직") or [])[:1]
if isinstance(line, dict) and line.get("로직")
]
+42 -3
View File
@@ -1,19 +1,22 @@
"""M02 일위대가 계산 · 알약 길(PLAN 39-1a · 설계_정함 1장) — `/api/m02/layers/{층}/labor/{열}/…`.
문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 ·
여기는 저장하지 않는 셈만. 층 권한 = `Router_Items._root`(읽기 권한 · `M02_Layer_Access` 한 벌).
여기는 저장하지 않는 셈만 + 서고(단가승격 · 사전조합 · 50-3) 새로 · 복사(키는 서버가 줌 · 고침 ·
지우기는 양식 길 그대로). 층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌).
⚠ 등록은 `main.py`(로그인만).
"""
from __future__ import annotations
from typing import Any
from typing import Any, Literal
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
@@ -62,3 +65,39 @@ def labor_pills(
) -> dict:
"""면적산출 알약 — 로직 물량 알약 「이름|단위」 + 자재 알약 「자재|MT키」(저장본)."""
return _call(_root(session, layer, owner), _pills, column)
class LibraryBody(BaseModel):
"""새로 = `문서` · 복사 = `본뜬` {층, 주인, 키}(원본 읽기 권한 · 못 읽는 참조는 같이 복사)."""
문서: dict[str, Any] | None = None
본뜬: dict[str, Any] | None = None
def _copy(kind: str, doc: dict[str, Any], source: refs.Here, ref: dict) -> dict[str, Any]:
return refs.create(kind, refs.carry({**doc, "본뜬": ref}, source))
@router.post("/layers/{layer}/library/{kind}")
def new_library(
layer: Layer,
kind: Literal["promote", "precombo"],
body: LibraryBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""서고 새로 · 복사 — 키는 서버가 그 층 다음 번호(SP · SC) · 답 `이름` = 새 키."""
folder = _root(session, layer, owner, write=True)
if body.본뜬 is None:
return _call(folder, refs.create, kind, body.문서 or {})
src = body.본뜬
if src.get("층") not in ("system", "company", "personal"):
raise HTTPException(status_code=400, detail="본뜬 층이 틀림")
got = access.grant(session, src["층"], src.get("주인")) # 원본 읽기 권한
ref = {"층": got.layer, "주인": got.owner, "키": str(src.get("키") or "")}
if refs.kind_of(ref["키"]) != kind:
raise HTTPException(status_code=400, detail="본뜬 종류가 틀림")
doc = refs.read_ref(ref) if refs._KEY.match(ref["키"]) else None
if doc is None:
raise HTTPException(status_code=404, detail=f"없는 {refs.TITLE[kind]} 「{ref['키']}」")
return _call(folder, _copy, kind, doc, (got.layer, got.owner), ref)
@@ -176,7 +176,7 @@ def copy_item(
have = store.read(kind, new_id)["문서"]
body = {k: v for k, v in got.items() if k != "계산값"}
body = {**body, "열": new_id, number: have.get(number)}
body = store._server(kind, body)
body = store._server(kind, body, source)
with store._LOCK:
atomic_write_json(store._path(kind, new_id), body)
store._refresh_labor(new_id) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시
@@ -3,7 +3,8 @@
* 짝(CLAUDE.md 5장 ①): `M02_Labor_Doc.py` `settle` 과 같은 규칙 —
* 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
* 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에
* 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. */
* 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만.
* 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`). */
export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const;
export type Money = Record<(typeof MONEY_KEYS)[number], number>;
@@ -11,8 +12,17 @@ export type QtySource = "손" | "칸" | "없음";
/** 문서의 수량 칸 — null = 면적산출 알약 · {손} = 손 입력 */
export type Qty = { 손: number } | null;
/** 참조 — 단가승격(SP) · 사전조합(SC) 문서(50-3) */
export interface LaborRef {
층: string;
주인: string | null;
키: string;
}
export interface CalcLine {
/** 참조 줄은 서버 답에 없음(빈 글로 다룸) */
로직: string;
참조?: LaborRef;
알약?: string | null;
분모?: string | null;
배: number;
@@ -47,7 +57,7 @@ export interface LaborCalc {
/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */
export interface LaborQtyDoc {
담은로직?: { 로직: string; 수량?: Qty }[];
담은로직?: { 로직?: string; 참조?: LaborRef; 수량?: Qty }[];
자재?: { 코드: string; 수량?: Qty }[];
}
@@ -57,6 +67,12 @@ function num(raw: unknown): number | null {
return String(raw).trim() !== "" && Number.isFinite(n) ? n : null;
}
/** 줄 가름 — 로직 키 · 참조 줄은 「참조|층|주인|키」(짝 `M02_Labor_Doc.line_id`) */
export function lineId(one: { 로직?: string; 참조?: LaborRef }): string | undefined {
if (one.참조) return `참조|${one.참조.층}|${one.참조.주인 ?? ""}|${one.참조.키}`;
return one.로직;
}
function zero(): Money {
return { 노무비: 0, 재료비: 0, 경비: 0, 계: 0 };
}
@@ -91,7 +107,7 @@ export function settle(
const v = num(raw);
if (v !== null) have[k] = v;
}
const hands = new Map((doc.담은로직 ?? []).map((one) => [one.로직, one.수량 ?? null]));
const hands = new Map((doc.담은로직 ?? []).map((one) => [lineId(one), one.수량 ?? null]));
const mhands = new Map((doc.자재 ?? []).map((one) => [one.코드, one.수량 ?? null]));
const base = zero();
const done = zero();
@@ -99,7 +115,7 @@ export function settle(
if (m) for (const k of MONEY_KEYS) sum[k] += m[k];
};
const 줄 = result.줄.map((line) => {
const [qty, source] = pick(hands.get(line.로직), line.알약, have);
const [qty, source] = pick(hands.get(lineId(line)), line.알약, have);
const own = line.기준 ? reflect(line.기준, qty, num(line.배) || 1) : null;
add(base, line.기준);
add(done, own);
@@ -25,6 +25,7 @@ from common_util import common_util_spreadsheet as spreadsheet
from common_util.common_util_auth import verify_session
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool, run_with_connection
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
@@ -278,6 +279,8 @@ async def list_layer(
row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}")
if (row["종류"], row["이름"]) in store.IN_USE: # 화면이 지우기 확인 창 전에 막음
row["쓰는곳"] = store.IN_USE[(row["종류"], row["이름"])]
if row["종류"] in layers.LIBRARY_KINDS: # 서고(50-3) — 보이는 이름 · 단위
row.update(refs.summary(folder, row["종류"], row["이름"]))
return {"층": layer, "양식": rows}
@@ -324,6 +327,8 @@ async def save_layer_template(
session, layer, project_id=project_id, user_id=user_id, owner=owner, write=True
)
document = body.문서
if layer == "project" and kind in layers.LIBRARY_KINDS:
raise HTTPException(status_code=400, detail="프로젝트는 서고 문서를 안 둠 — 굳힌 사본만")
try:
layers.check_skeleton(kind, document) # 빈 `{}` · 뼈대 없는 문서는 파일 안 씀
except ValueError as error:
@@ -406,6 +411,10 @@ async def delete_layer_template(
layers.check_name(name)
except ValueError as error:
raise _bad(error) from error
try: # 서고 문서를 담은 일위대가 · 사전조합이 있으면 409(50-3)
await asyncio.to_thread(refs.refuse_used, folder, kind, name)
except store.StoreError as error:
raise HTTPException(status_code=error.status, detail=error.detail) from error
if not 판:
raise HTTPException(status_code=400, detail="지울 양식의 판이 필요합니다.")
try:
+18 -5
View File
@@ -153,6 +153,8 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
(정본 = 나무 · 머리는 경로에서 다시 만듦 · `tree.keep_paths`)."""
path = _path(kind, name)
_check_skeleton(kind, doc)
if kind in layers.LIBRARY_KINDS: # 서고 문서 키 = 파일 이름(50-3)
doc = {**doc, "키": name}
origins = _take_origins(doc) if kind == "table" else {}
if kind == "table" and name == STRUCTURE_TABLE:
doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기)
@@ -186,6 +188,10 @@ def delete(kind: str, name: str, version: str | None = None) -> None:
path = _path(kind, name)
if (kind, name) in IN_USE:
raise StoreError(409, f"쓰는 양식이라 못 지움 「{name}」 — {IN_USE[(kind, name)]}")
if kind in layers.LIBRARY_KINDS:
from M02_MasterTemplete import M02_Labor_Ref as refs
refs.refuse_used(root(), kind, name)
with _LOCK:
if not path.is_file():
raise StoreError(404, f"없는 양식 「{name}」")
@@ -387,7 +393,7 @@ def copy_from(
body = read(kind, source)["문서"]
body = read(kind, source)["문서"] if body is None else body
doc = {**body, **{k: target.get(k) for k in ("열", _KEY[kind])}}
doc = _server(kind, {k: v for k, v in doc.items() if k != "계산값"})
doc = _server(kind, {k: v for k, v in doc.items() if k != "계산값"}, source_root)
with _LOCK:
atomic_write_json(_path(kind, column), doc)
info = _info(kind, _path(kind, column))
@@ -493,14 +499,21 @@ def renumber() -> dict[str, list[str]]:
return {"옮김": moved, "지움": gone, "바꿈": changed, "새로": made}
def _server(kind: str, doc: Any) -> Any:
"""서버가 적는 값 — 통합문서 계산값(5장 ②) · 일위대가 정리 · 계산값(`M02_Labor_Doc`)."""
def _server(kind: str, doc: Any, source: Path | None = None) -> Any:
"""서버가 적는 값 — 통합문서 계산값(5장 ②) · 일위대가 정리 · 계산값(`M02_Labor_Doc`) ·
서고 문서 정리(`M02_Labor_Ref` · 50-3) · `source` = 층 사이 복사의 원본 층 폴더(지금 층이
못 읽는 참조는 같이 복사)."""
if kind in SHEETS:
return _server_values(doc)
if kind == "labor":
if kind in ("labor", *layers.LIBRARY_KINDS):
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
return labor.server(doc)
if source is not None and isinstance(doc, dict) and kind != "promote":
doc = refs.carry(doc, refs.where(source))
if kind == "labor":
return labor.server(doc)
return refs.clean_doc(kind, doc)
return doc
+20 -2
View File
@@ -28,7 +28,9 @@ from config import config_system
LAYERS = ("system", "company", "personal", "project")
# area = 면적산출 — 통합문서(구조물집계표 열마다 · `A00_Common/spreadsheet/`)
# labor = 일위대가(구조물 열마다 · `M02_Labor_Doc`)
KINDS = ("table", "drawing", "structure", "labor", "area")
# promote = 단가승격(SP-) · precombo = 사전조합(SC-) — 일위대가 조합 서고(`M02_Labor_Ref`)
LIBRARY_KINDS = ("promote", "precombo")
KINDS = ("table", "drawing", "structure", "labor", "area", *LIBRARY_KINDS)
TEMPLATES_DIRNAME = "templates"
INITIAL_DIRNAME = "_initial"
MANIFEST_NAME = "manifest.json"
@@ -171,6 +173,8 @@ _SKELETON = {
"structure": ("도면.entities",),
"labor": ("담은로직",),
"area": ("시트",),
"promote": ("담은로직",),
"precombo": ("담은로직",),
}
@@ -230,6 +234,17 @@ def delete_template(
return True
def layer_files(kind: str) -> list[tuple[str, Path]]:
"""모든 층(마스터 · 회사 · 개인 · 프로젝트 빼고)의 `kind/*.json` — (어디, 파일)."""
root = storage_root()
found = [("마스터", p) for p in sorted((system_dir() / kind).glob("[!.]*.json"))]
found += [
(f"회사 {p.parts[-4]}", p) for p in sorted(root.glob(f"*/templates/{kind}/[!.]*.json"))
]
personal = sorted(root.glob(f"*/*/templates/{kind}/[!.]*.json"))
return found + [(f"개인 {p.parts[-5]}/{p.parts[-4]}", p) for p in personal]
# ── manifest ──────────────────────────────────────────
@@ -283,7 +298,8 @@ def copy_templates(
) -> list[str]:
"""`source_dir` 층의 양식을 `target_dir` 로 복사 → 복사한 `종류/이름` 목록.
`kind`·`name` 으로 좁힘 · `only_missing` 이면 이미 있는 파일은 건너뜀(더하기만).
`kind`·`name` 으로 좁힘(안 좁히면 서고 종류 `LIBRARY_KINDS` 는 뺌 — 프로젝트는 굳힌 사본) ·
`only_missing` 이면 이미 있는 파일은 건너뜀(더하기만).
manifest 에 출처(층 · 이름 · 판 · `source_ref`)를 적음. 출처 manifest 의 원래 출처는
적지 않음 — 「어디서 가져왔나」 한 단계만.
"""
@@ -292,6 +308,8 @@ def copy_templates(
rows = list_templates(source_dir)
if kind is not None:
rows = [row for row in rows if row["종류"] == check_kind(kind)]
else:
rows = [row for row in rows if row["종류"] not in LIBRARY_KINDS]
if name is not None:
rows = [row for row in rows if row["이름"] == check_name(name)]
manifest = read_manifest(target_dir)