refactor(841-1): B09 대가 · 산근 단가 원천 = M02 엔진 — A 걸음

- 산근 Solver 단가 = M02 엔진 줄 금액 ÷ 수량(labor_doc.run_line 날 값) · B09 단가 책 · 바탕 표 안 읽음
- 대가 블록 줄 노무 · 자재 · 기계 = 엔진 단가(own) · 자재 줄 = 엔진 기준단가 · 고정 줄 단가 책 덮어쓰기 걷음
- 대가 단가 고친 칸(daega_cover 노 · 재 · 경) 걷음 · 품(수량) 고친 칸 둠
- 기계 시간당 = M02 엔진 기계경비 값(운전사 계수 16/12 · 25/20 따로 곱 0) — 견본 노체포설 4,522 → 3,548 = M02
- 운반 단가 미리보기(logic-preview) = 엔진 단가
- 낡은 시험(B09 단가 책 입력 견본)은 C 걸음에서 정리

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-15 15:40:47 +09:00
co-authored by Claude Opus 5.5
parent 4f6ade2c23
commit f2d51252a0
5 changed files with 115 additions and 123 deletions
+24 -25
View File
@@ -47,7 +47,7 @@ from B09_Estimation.B09_Estimation_Calc_Base import (
)
PARTS = ("labor", "material", "expense")
COVER_KEYS = ("qty", *PARTS)
COVER_KEYS = ("qty",) # 841-1 단가 고친 칸 걷음 — 품(수량)만
TAB = "dg"
NO_LINES = "구성 줄 없음" # 줄 0 개 일위대가의 빈 줄 이름
COST = {"노무비": "labor", "재료비": "material", "경비": "expense"}
@@ -55,9 +55,9 @@ WAYS = {"버림": rounddown, "올림": roundup, "반올림": round_half}
COPY = "@copy"
ADD = "@add"
WHY = {
"labor": "노임 단가 없음",
"material": "자재 적용 단가 없음",
"machine": "중기 시간 단가 없음",
"labor": "노임 단가 없음(M02 엔진)",
"material": "자재 단가 없음(M02 엔진)",
"machine": "기계 시간당 없음(M02 엔진)",
"daega": "부른 대가 합계 없음",
"sangeun": "산근 단가 없음(145-1)",
}
@@ -96,16 +96,18 @@ def _prices(line: dict[str, Any], books: dict[str, Any], sums: dict) -> dict[str
"""줄 비목 단가 — 그 비목이 없는 줄은 0 · 원천이 없으면 None."""
kind, key = line["kind"], line.get("key")
out: dict[str, float | None] = dict.fromkeys(PARTS, 0.0)
if kind == "labor":
out["labor"] = num((books["labor"].get(key) or {}).get("price"))
elif kind == "material":
row = books["material"].get(key) or {}
# 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈 — 두 번 안 셈)
out["material"] = 0.0 if row.get("supply") in SUPPLY else num(row.get("price"))
elif kind in ("machine", "daega", "sangeun"):
row = {"machine": books["machine"], "daega": sums, "sangeun": books["sangeun"]}[kind].get(
key
) or {}
if kind in (
"labor",
"material",
"machine",
): # 841-1 = M02 엔진 단가(`own`) · B09 단가 책 안 읽음
own = line.get("own")
out = {p: num(own.get(p)) if p in own else 0.0 for p in PARTS} if own else _none()
row = (books["material"].get(key) or {}) if kind == "material" else {}
if row.get("supply") in SUPPLY: # 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락)
out["material"] = 0.0
elif kind in ("daega", "sangeun"):
row = {"daega": sums, "sangeun": books["sangeun"]}[kind].get(key) or {}
for part in PARTS:
out[part] = None if row.get("blank") else num(row.get(part)) # 빈 줄 대가 = 빈칸
elif kind == "fixed":
@@ -115,21 +117,18 @@ def _prices(line: dict[str, Any], books: dict[str, Any], sums: dict) -> dict[str
return out
def _none() -> dict[str, float | None]:
return dict.fromkeys(PARTS, None)
def _fixed(line: dict[str, Any], books: dict[str, Any]) -> dict[str, float | None]:
"""고정 줄 단가(705-1) — 줄 단가 위에 이은 비목만 단가 책 값."""
"""고정 줄 단가(705-1) — 줄 단가 그대로 · 이은 자재가 관급 · 별도계상이면 재료 0(841-1 단가 책
값으로 다시 안 덮음)."""
own, link = line.get("own") or {}, line.get("link") or {}
out = {part: num(own.get(part)) or 0.0 for part in PARTS}
labor = books["labor"].get(link.get("labor")) if link.get("labor") else None
if labor and num(labor.get("price")) is not None:
out["labor"] = num(labor["price"])
row = books["material"].get(link.get("material")) if link.get("material") else None
if row and (row.get("supply") in SUPPLY or num(row.get("price")) is not None):
out["material"] = 0.0 if row.get("supply") in SUPPLY else num(row["price"])
machine = books["machine"].get(link.get("machine")) if link.get("machine") else None
if machine and not machine.get("blank"):
for part in PARTS:
if num(machine.get(part)) is not None:
out[part] = num(machine[part])
if row and row.get("supply") in SUPPLY:
out["material"] = 0.0
return out
+17 -20
View File
@@ -39,16 +39,16 @@ import {
} from "./B09_Estimation_Calc_Base";
const PARTS = ["labor", "material", "expense"] as const;
const COVER_KEYS = ["qty", ...PARTS] as const;
const COVER_KEYS = ["qty"] as const; // 841-1 단가 고친 칸 걷음 — 품(수량)만
const TAB = "dg";
const NO_LINES = "구성 줄 없음"; // 줄 0 개 일위대가의 빈 줄 이름(파이썬 짝)
export const COPY = "@copy";
/** 붙인 줄 덮개 열쇠(705-2) */
export const ADD = "@add";
const WHY: Record<string, string> = {
labor: "노임 단가 없음",
material: "자재 적용 단가 없음",
machine: "중기 시간 단가 없음",
labor: "노임 단가 없음(M02 엔진)",
material: "자재 단가 없음(M02 엔진)",
machine: "기계 시간당 없음(M02 엔진)",
daega: "부른 대가 합계 없음",
sangeun: "산근 단가 없음(145-1)",
};
@@ -103,14 +103,17 @@ const blanks = (): Parts => ({ labor: null, material: null, expense: null });
function linePrices(line: Row, books: Row, sums: Record<string, Row>): Parts {
const { kind, key } = line;
const out = zeros();
if (kind === "labor") out.labor = num((books.labor[key] || {}).price);
else if (kind === "material") {
const row: Row = books.material[key] || {};
// 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈 — 두 번 안 셈)
out.material = (SUPPLY as readonly string[]).includes(row.supply) ? 0 : num(row.price);
} else if (kind === "machine" || kind === "daega" || kind === "sangeun") {
const src: Row = ({ machine: books.machine, daega: sums, sangeun: books.sangeun } as Row)[kind];
let out = zeros();
if (kind === "labor" || kind === "material" || kind === "machine") {
// 841-1 = M02 엔진 단가(`own`) · B09 단가 책 안 읽음
const own: Row | undefined = line.own;
out = blanks();
if (own) for (const part of PARTS) out[part] = part in own ? num(own[part]) : 0;
const row: Row = kind === "material" ? books.material[key] || {} : {};
// 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락)
if ((SUPPLY as readonly string[]).includes(row.supply)) out.material = 0;
} else if (kind === "daega" || kind === "sangeun") {
const src: Row = ({ daega: sums, sangeun: books.sangeun } as Row)[kind];
const row: Row = src[key] || {};
for (const part of PARTS) out[part] = row.blank ? null : num(row[part]); // 빈 줄 대가 = 빈칸
} else if (kind === "fixed") return fixedPrices(line, books);
@@ -118,20 +121,14 @@ function linePrices(line: Row, books: Row, sums: Record<string, Row>): Parts {
return out;
}
/** 고정 줄 단가(705-1) — 줄 단가 위에 이은 비목만 단가 책 값(짝 `_fixed`) */
/** 고정 줄 단가(705-1) — 줄 단가 그대로 · 이은 자재가 관급 · 별도계상이면 재료 0(짝 `_fixed`) */
function fixedPrices(line: Row, books: Row): Parts {
const own: Row = line.own || {};
const link: Row = line.link || {};
const out = zeros();
for (const part of PARTS) out[part] = num(own[part]) ?? 0;
const labor: Row | undefined = link.labor ? books.labor[link.labor] : undefined;
if (labor && num(labor.price) !== null) out.labor = num(labor.price);
const row: Row | undefined = link.material ? books.material[link.material] : undefined;
const supplied = !!row && (SUPPLY as readonly string[]).includes(row.supply);
if (row && (supplied || num(row.price) !== null)) out.material = supplied ? 0 : num(row.price);
const machine: Row | undefined = link.machine ? books.machine[link.machine] : undefined;
if (machine && !machine.blank)
for (const part of PARTS) if (num(machine[part]) !== null) out[part] = num(machine[part]);
if (row && (SUPPLY as readonly string[]).includes(row.supply)) out.material = 0;
return out;
}
+20 -7
View File
@@ -243,18 +243,18 @@ class Builder:
part = PART.get(str(add.get("비목")), "expense")
return {**head, "kind": "pct", "part": part, "base": base, "spec": f"{label}의 %",
"unit": "%", "qty": round(value / under * 100, 6)} # fmt: skip
kind = item.get("종류")
kind, own = item.get("종류"), _own(x, qty)
if kind == "인력":
key = goods.get("키") or str(item.get("요소") or "")
return {**head, "kind": "labor", "key": key, "qty": qty}
return {**head, "kind": "labor", "key": key, "qty": qty, "own": own}
if kind == "재료":
if not goods.get("키"):
return {**head, "kind": "blank", "qty": qty, "why": x.get("까닭") or "품목 없음"}
return {**head, "kind": "material", "key": goods["키"], "spec": goods.get("규격") or "",
"qty": qty} # fmt: skip
"qty": qty, "own": own} # fmt: skip
# 로직 줄 — `Sangeun.call_of` 판정: 기계경비 로직 = 중기 · 그 밖 = 하위 산근
if call and call.get("machine"):
return {**head, "kind": "machine", "key": call["machine"], "qty": qty}
if call and call.get("machine"): # 기계 시간당 = M02 엔진 값(841-1)
return {**head, "kind": "machine", "key": call["machine"], "qty": qty, "own": own}
if call and call.get("code"):
key = sg.sangeun_key(call["code"], call["given"])
return {**head, "kind": "sangeun", "key": key, "qty": qty}
@@ -559,11 +559,24 @@ def _row_of(key: str, where: dict) -> str:
return f"{where[line]}@{PART[cost]}" if cost else str(where[line])
def _own(x: dict, qty: float | None) -> dict[str, float] | None:
"""엔진 줄 비목 단가(841-1) = 엔진 금액 ÷ 엔진 수량 · 수량 0 = 0 · 못 셈 = None(빈칸)."""
money = x.get("비목") or {}
if not qty:
return dict.fromkeys(PART.values(), 0.0) if qty == 0 else None
return {PART[k]: (_f(v) or 0.0) / qty for k, v in money.items() if k in PART}
def _material(whole: Any, mat: dict, qty: Any) -> dict[str, Any]:
"""자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가."""
"""자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가 ·
단가 = M02 엔진 자재 기준단가(841-1 · 조합 셈 답 `기준단가` · 없으면 사본 단가 묶음으로)."""
row = (whole.get(mat.get("코드")) if mat.get("코드") else None) or {}
names = {k: row.get(v) or "" for k, v in (("name", "이름"), ("spec", "규격"), ("unit", "단위"))}
return {"kind": "material", "key": mat.get("코드"), **names, "qty": _f(qty)}
price = mat["기준단가"] if "기준단가" in mat else None
if price is None and mat.get("코드"):
price = labor_doc.material_price(whole, mat["코드"], mat.get("단위"))["기준단가"]
own = None if _f(price) is None else {"material": _f(price)}
return {"kind": "material", "key": mat.get("코드"), **names, "qty": _f(qty), "own": own}
def _engine_bases(engine: list[dict], items: list[dict]) -> dict[str, float]:
+3 -6
View File
@@ -160,7 +160,7 @@ def compute_sheets(
화면 짝 = `B09_Estimation_UI_Shell.computeSheets`(산근만 서버 답 그대로) ·
memo = 두 번 셈의 앞 산근 풀이(`Sangeun.collect`)."""
base = compute_base(inputs, user)
sg = sangeun.build(root, base, user, memo) # 145-1 — 굳힌 단가로 풀이(+ 166-2 손 줄 로직)
sg = sangeun.build(root, user, memo) # 145-1 — M02 엔진 값으로 풀이(841-1 · + 166-2 손 줄 로직)
sg = sg_cover.cover_sangeun(sg, user.get("sg_cover")) # 719-3 이 공사 산근 고친 칸
by_key = {row["key"]: row for row in sg["sg_list"]}
labor = compute_labor(
@@ -423,14 +423,11 @@ class PreviewBody(BaseModel):
def logic_preview(root: Path, body: PreviewBody) -> dict[str, Any]:
"""로직 한 산근 미리보기 — 단가 = 굳힌 책(없는 줄 = 지금 M01) · 사용자 대비표 고름."""
"""로직 한 산근 미리보기 — 단가 = M02 엔진(841-1 · 그 로직 닫힌 묶음 · B09 단가 책 안 읽음)."""
src = {"code": body.code, "given": sangeun._clean_given(body.given), "rate": body.rate,
"picks": body.picks} # fmt: skip
hands = [("preview", "미리보기", src)]
keys = sangeun.hand_resource_keys({"haul_refs": {"preview": {"tab": "logic", **src}}})
user = store.read_user(root)
base = compute_base(prices.build_inputs(store.read_book(root), keys), user)
solver = sangeun.Solver(base)
solver = sangeun.Solver()
block = solver.add(sangeun.hand_whole(hands), src, None)
text = [
*block["head"],
+51 -65
View File
@@ -62,6 +62,7 @@ import master_sheet_line as msl # noqa: E402 — 계산서 줄 덧칸(714-1) ·
PARTS = ("labor", "material", "expense")
COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"}
PART_COST = {v: k for k, v in COST_PART.items()}
PCT_OF = {**COST_PART, "인건비": "people"} # % 덧줄 밑수 → 덮개 다시 셈(719-3)
DEPTH = 4 # 하위 산근 깊이 상한
NO_SOURCE = "조합 밖 토공 · 운반 · 사토 · 준비공은 산근 없음"
@@ -165,49 +166,15 @@ def rows_of(folder: Path, whole: Any = None) -> list[dict[str, Any]]:
# ── 풀이 ──────────────────────────────────────────────────────────────
class _Seen(dict):
"""읽은 키를 적는 단가 표 — 두 번 셈(183-1)에서 첫 풀이가 본 값만 견줌."""
def __init__(self, *args: Any) -> None:
super().__init__(*args)
self.seen: set[Any] = set()
def __contains__(self, key: object) -> bool:
self.seen.add(key)
return super().__contains__(key)
def __getitem__(self, key: Any) -> Any:
self.seen.add(key)
return super().__getitem__(key)
def get(self, key: Any, default: Any = None) -> Any:
self.seen.add(key)
return super().get(key, default)
def same(self, other: dict) -> bool:
"""본 키마다 있음 · 값이 `other` 와 같음."""
return all(
dict.__contains__(self, k) == dict.__contains__(other, k)
and dict.get(self, k) == dict.get(other, k)
for k in self.seen
)
class _Prices:
"""B09 표 단가 — 노무 · 중기(노 · 재 · 경) · 자재 적용 단가(키 → 값)."""
"""엔진 단가(841-1) — M02 엔진이 사본 단가 묶음으로 낸 줄 금액 ÷ 수량 · 텍스트 수식 글이 읽음
(노무 · 자재 = 수 · 중기 = 노 · 재 · 경). B09 단가 책 · 바탕 표는 안 읽음."""
def __init__(self, sheets: dict[str, Any]) -> None:
self.labor = _Seen({r["key"]: num(r.get("price")) for r in sheets.get("labor") or []})
self.machine = _Seen({r["key"]: r for r in sheets.get("machine") or []})
self.material = _Seen(
{r["key"]: num(r.get("applied")) for r in sheets.get("compare") or []}
)
def same(self, other: _Prices) -> bool:
"""첫 풀이가 본 단가가 `other` 에서도 같음 — 같으면 풀이도 같음."""
return all(
getattr(self, k).same(getattr(other, k)) for k in ("labor", "machine", "material")
)
def __init__(self) -> None:
# ponytail: 키 하나 = 단가 하나(사본마다 기준 시점이 달라도 마지막 본 값) — 글 짓기 전용
self.labor: dict[str, float | None] = {}
self.material: dict[str, float | None] = {}
self.machine: dict[str, dict[str, float | None]] = {}
def _unit(row: dict) -> tuple[str, Decimal]:
@@ -323,6 +290,10 @@ def spec_text(whole: Any, given: dict[str, Any]) -> tuple[str, str]:
return ", ".join(f"{k}={shown(k, v)}" for k, v in given.items()), raw
def _float(value: Any) -> Any:
return float(value) if isinstance(value, Decimal) else value
def _money(price: float | None, qty: float | None) -> float | None:
"""줄 금액 = ROUNDDOWN(단가 × 수량, 1)(엑셀 자리)."""
return None if price is None or qty is None else rounddown(price * qty, 1)
@@ -340,8 +311,9 @@ def block_totals(lines: list[dict[str, Any]]) -> dict[str, float | None]:
class Solver:
"""한 번 셈 — 산근 블록을 첫 등장 차례로 모음(하위 산근 포함)."""
def __init__(self, sheets: dict[str, Any]) -> None:
self.prices = _Prices(sheets)
def __init__(self) -> None:
self.prices = _Prices()
self.eng: list[dict[str, Any]] = [] # 지금 푸는 로직의 엔진 줄(호표 차례 · 841-1)
self.blocks: dict[str, dict[str, Any]] = {}
def add(
@@ -367,10 +339,13 @@ class Solver:
unit, times = _unit(row)
rate = Decimal(str(src["rate"])) / 100 if src.get("rate") not in (None, 100) else None
lines: list[dict[str, Any]] = []
# 841-1 단가 = M02 엔진 줄 금액 ÷ 수량(켜쌓기율 없이 · 날 값 · B09 단가 책 안 읽음)
eng = labor_doc.run_line(whole, code, src["given"], None, src.get("picks"), raw=True)
try:
env = _env(whole, row, src["given"])
with mf.mp.choosing(src.get("picks")):
for n, item in enumerate(row.get("호표", [])):
self.eng = eng.get("줄") or [] # 하위 산근 풀이가 바꿔 놓아도 되돌림
lines.append(self._line(whole, item, n, env, times, rate, depth))
ss.places(row, lines) # 줄 자리(792-3)
self._extras(whole, row, env, lines)
@@ -434,6 +409,14 @@ class Solver:
at += n
return text[:start], text[-1]
def _each(self, n: int, part: str, q: float) -> float | None:
"""엔진 줄 n 비목 단가 = 엔진 금액 ÷ 엔진 수량 · 안 쓰는 줄(수량 0) = 0 · 못 셈 = None."""
x = self.eng[n] if n < len(self.eng) else {}
qty, money = num(_float(x.get("수량"))), (x.get("비목") or {}).get(PART_COST[part])
if not qty:
return 0.0 if q == 0 else None
return None if money is None else float(money) / qty
def _blank(self, item: dict, why: str) -> dict[str, Any]:
part = COST_PART.get(item.get("비목"), "material")
return {"kind": "blank", "name": item.get("이름", ""), "spec": "", "qty": None,
@@ -451,11 +434,12 @@ class Solver:
zero = dict.fromkeys(PARTS, 0.0)
kind = item.get("종류")
if kind == "로직":
return self._logic_line(whole, item, env, q, base, zero, depth)
return self._logic_line(whole, item, n, env, q, base, zero, depth)
if kind == "인력":
key = mf.fill(item["요소"], env) if isinstance(item["요소"], str) else ""
price = self.prices.labor.get(key)
why = None if price is not None else f"노무비목록표에 없음 「{key}」"
price = self._each(n, "labor", q)
self.prices.labor[key] = price
why = None if price is not None else f"노임 단가 없음(M02 엔진) 「{key}」"
return {**base, **zero, "kind": "labor", "ref": {"tab": "labor", "key": key},
"price": {"labor": price}, "labor": _money(price, q), "why": why} # fmt: skip
ref = (
@@ -468,29 +452,33 @@ class Solver:
key = str(
goods.get("키") or (source or "").split(".")[0] or (ref if isinstance(ref, str) else "")
)
if key not in self.prices.material: # 자리표시 글(「MO:경유:전국평균」) = 대비표 키로
try:
key = str((whole.get(key) or {}).get("키") or key)
except (mf.FormulaError, KeyError, TypeError, ValueError):
pass
try: # 자리표시 글(「MO:경유:전국평균」) = 그 줄 키로
key = str((whole.get(key) or {}).get("키") or key)
except (mf.FormulaError, KeyError, TypeError, ValueError):
pass
part = COST_PART.get(item.get("비목"), "material")
price = self.prices.material.get(key)
if q == 0:
price = price if price is not None else 0.0 # 안 쓰는 재료 줄 — 금액 0
why = None if price is not None else f"자재단가대비표에 없음 「{key}」"
price = self._each(n, part, q)
self.prices.material[key] = price
why = None if price is not None else f"자재 단가 없음(M02 엔진) 「{key}」"
spec = goods.get("규격") or ""
ref = {"tab": "compare", "key": key}
return {**base, **zero, "spec": spec, "kind": "material", "ref": ref,
"price": {part: price}, part: _money(price, q), "why": why,
"_goods": goods} # fmt: skip
def _logic_line(self, whole, item, env, q, base, zero, depth) -> dict[str, Any]:
def _logic_line(self, whole, item, n, env, q, base, zero, depth) -> dict[str, Any]:
call = call_of(whole, item, env)
if call and "machine" in call:
keys = [call["machine"]] if call["machine"] else []
m = self.prices.machine.get(keys[0]) if keys else None
price = {p: num((m or {}).get(p)) for p in PARTS}
why = None if m else f"중기목록표에 없음 「{keys[0] if keys else item.get('이름')}」"
price = {p: self._each(n, p, q) for p in PARTS} # 기계 시간당 = M02 엔진 값(841-1)
m = None not in price.values()
if keys:
self.prices.machine[keys[0]] = price
why = (
None
if m
else f"기계 시간당 없음(M02 엔진) 「{keys[0] if keys else item.get('이름')}」"
)
amounts = {p: _money(price[p], q) if m else None for p in PARTS}
ref = {"tab": "machine", "key": keys[0] if keys else ""}
return {**base, "kind": "machine", "ref": ref,
@@ -618,16 +606,15 @@ def hand_resource_keys(user: dict[str, Any] | None) -> list[str]:
def collect(
entries: list[tuple[str, Path]],
sheets: dict[str, Any],
hands: list[tuple[str, str, dict[str, Any]]] | None = None,
memo: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""`entries` = [(구조물 id, 사본 폴더)] → 블록 · 목록 · 「연결 없음」 ·
`hands` = 손 줄 로직(166-2 · 사본 뒤 번호 · 마스터 로직) ·
memo = 같은 사본 · 손 줄의 앞 풀이(두 번 셈) — 앞 풀이가 본 단가가 같으면 그 풀이 사본."""
solver = Solver(sheets)
if memo and memo["prices"].same(solver.prices):
if memo and "sg" in memo: # 841-1 풀이 = 사본 · 엔진만(바탕 표 안 읽음) → 앞 풀이 그대로
return copy.deepcopy(memo["sg"])
solver = Solver()
for owner, folder in entries:
whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
for src in rows_of(folder, whole):
@@ -652,13 +639,12 @@ def collect(
"sg_hand": hand_keys,
}
if memo is not None:
memo.update(prices=solver.prices, sg=copy.deepcopy(out))
memo.update(sg=copy.deepcopy(out))
return out
def build(
project_root: str | Path,
sheets: dict[str, Any],
user: dict[str, Any] | None = None,
memo: dict[str, Any] | None = None,
) -> dict[str, Any]:
@@ -676,4 +662,4 @@ def build(
entries += [
(f"서고 {(m.get('원본') or {}).get('name') or f.name}", f) for f, m in copies(project_root)
]
return collect(entries, sheets, hand_sources(user), memo)
return collect(entries, hand_sources(user), memo)