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

This commit is contained in:
2026-10-15 23:36:37 +09:00
14 changed files with 2022 additions and 240 deletions
+43 -14
View File
@@ -5,8 +5,11 @@
다음 머리 앞까지 한 블록 ·
출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 ·
그 밖 = 건설품셈 ·
직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로).
견줌(`candidates`) = 같은 책 조항 같음 1.0 · 앞 마디 같음 0.5(조항이 안 맞으면 후보 아님) ·
직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로) ·
글(338) = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안(맞춤 지문 재료) · 산출근거 열이 빈 줄은
그 오른쪽 ~ 돈 머리(맨 오른쪽 묶음) 앞 칸을 빈칸으로 이어 붙임(거창 꼴 · 돈 머리 없으면 `WIDE` 열).
견줌(`candidates`) = 같은 책 조항 같음 1.0 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) ·
앞 마디 같음 0.5(조항이 안 맞으면 후보 아님 · `clause`) · 조항 오타 「13-114」 = 「13-11-4」 ·
건설 공통 · 토목 덤 0.1 · 직종 겹침(보통인부 뺌) 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 ·
같은 점수는 글자 두 개 묶음(괄호 글 포함)이 가름 · 이름 · 낱말(괄호 글 포함 · 인력 · 기계 뺌)이
하나도 안 겹치는 근거 후보는(근거 맞음 = 조항 · 직종 · 단위 다 맞으면 빼고) 겹치는 근거 후보 뒤로 ·
@@ -31,6 +34,8 @@ BONUS_PARTS = ("공통", "토목")
_HEAD = re.compile(r"^(\d+)\.\s*(.*?)\s*\[([^\[\]]*)\]\s*$")
_BOOK = re.compile(r"품[셈줌]")
_TRADE = re.compile(r"^[\s*·\-]*([가-힣][가-힣\s]*?)\s*:\s*[\d,]")
_TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 「13-114」 = 13-11-4
GLINES, GCHARS, WIDE = 200, 300, 140
_TYPOS = (("깍기", "깎기"),)
_PLAIN_WORDS = frozenset({"인력", "기계", "장비"}) # 방법 낱말 — 「터널굴착 1발파당 인력」
_MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"}
@@ -41,6 +46,12 @@ def _flat(value: Any) -> str:
# ── 엑셀 블록 읽기 ─────────────────────────────────────────────────────
def _cell_text(value: Any) -> str:
if isinstance(value, float) and value == int(value):
value = int(value)
return str(value).strip() if value is not None else ""
@lru_cache(maxsize=2)
def _trade_names(whole_id: int) -> dict[str, str]:
"""{빈칸 뺀 이름 · 옛이름: 지금 이름} — 우리 인력(LB)."""
@@ -66,35 +77,46 @@ def sources(text: str) -> list[list[str]]:
continue
before = text[max(0, hit.start() - 12) : hit.start()]
book = "산림품셈" if "산림" in before or "임도" in before else "건설품셈"
if [book, num.group()] not in out:
out.append([book, num.group()])
typo = _TYPO_CLAUSE.match(num.group())
number = "{}-{}{}-{}".format(*typo.groups()) if typo else num.group()
if [book, number] not in out:
out.append([book, number])
return out
def _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]:
"""(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {})."""
"""(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {}) ·
돈 머리가 둘 묶음이면 맨 오른쪽(거창 꼴 · 값이 든 쪽)."""
for at, row in enumerate(rows[:10]):
flat = [_flat(v) for v in row]
if "산출근거" in flat:
money = {key: flat.index(head) for head, key in _MONEY.items() if head in flat}
last = len(flat) - 1
money = {
key: last - flat[::-1].index(head) for head, key in _MONEY.items() if head in flat
}
return at + 1, flat.index("산출근거"), money
return 0, 1, {}
def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]:
"""단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 금액}}`
"""단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 글, 금액}}`
(행 = 엑셀 1부터)."""
if not rows:
return {}
start, col, money = _columns(rows)
end = min(money.values()) if money and min(money.values()) > col + 1 else WIDE
names = trade_names()
blocks: dict[int, dict[str, Any]] = {}
block: dict[str, Any] | None = None
for at, row in enumerate(rows[start:], start + 1):
text = row[col] if col < len(row) else None
if not isinstance(text, str) or not text.strip():
continue
head = None if text[:1].isspace() else _HEAD.match(text)
head = None
if isinstance(text, str) and text.strip():
head = None if text[:1].isspace() else _HEAD.match(text)
else:
text = " ".join(_cell_text(v) for v in row[col + 1 : end] if _cell_text(v))
if not text:
continue
if head:
block = blocks.setdefault(
int(head.group(1)),
@@ -105,6 +127,7 @@ def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]:
"행": [at, at],
"출처": [],
"직종": [],
"글": [],
"금액": {
key: v
for key, c in money.items()
@@ -117,6 +140,8 @@ def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]:
if block is None:
continue
block["행"][1] = at
if len(block["글"]) < GLINES:
block["글"].append(text.rstrip()[:GCHARS])
block["출처"] += [one for one in sources(text) if one not in block["출처"]]
trade = _TRADE.match(text)
name = names.get(_flat(trade.group(1))) if trade else None
@@ -163,12 +188,16 @@ def _head(number: str) -> str:
return number.rsplit("-", 1)[0]
def _clause(theirs: list[list[str]], our: dict[str, Any]) -> tuple[float, list[str], str]:
"""(점수, 맞은 우리 조항 글, 꼬리표) — 같은 책만."""
def clause(theirs: list[list[str]], our: dict[str, Any]) -> tuple[float, list[str], str]:
"""엑셀 인용 조항 `[[책, 번호]]` ↔ 우리 로직(`ours()` 한 줄) → (점수, 맞은 우리 조항 글,
꼬리표) — 같은 책만 · 같음 1 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) · 앞 마디 0.5."""
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 = {_head(n) for n in nums}
near = sorted(n for n in our["조항"] if _head(n) in heads)
if near:
@@ -206,7 +235,7 @@ def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any]
our = table.get(one["로직"])
if not our:
continue
score, clauses, tag = _clause(theirs, our)
score, clauses, tag = clause(theirs, our)
if not score:
continue
shared = sorted(trades & our["직종"])
@@ -227,7 +256,7 @@ def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any]
why = {"조항": clauses, "직종": shared, "단위": unit, "점수": round(score, 3)}
theirs_loose = _loose_words(one["이름"])
overlap = any(a in b or b in a for a in loose for b in theirs_loose)
full = tag == "조항 같음" and unit and (bool(shared) or not trades) # 근거 맞음
full = tag != "앞 마디 같음" and unit and (bool(shared) or not trades) # 근거 맞음
why["까닭"] = " · ".join(tags)
extra = {"_겹침": dice, "_낱말": overlap or full}
scored.append((score, dice, {**hopyo_match._public(one), "근거": why, **extra}))
+219 -109
View File
@@ -1,39 +1,44 @@
"""M02 일위대가 실무자료 → 우리 일위대가 사전조합(PLAN 324-3).
"""M02 일위대가 실무자료 → 우리 일위대가 사전조합(PLAN 324-3 · 338-3 · 설계 3-6 · 3-7).
[확인하고 사전조합 만들기] 몸 = 사용자가 체크한 줄만 —
`줄 [{차례, 대상 [{로직|자재|호표, 입력값, 배, 몫}] | 뺌}]` · 직접 줄 호표 = `통째 {대상}`.
몸이 비면 400(확인 없이 들어가는 길 없음).
서버 재검사(브라우저 값을 받아 적지 않음 · 금액 · 품 칸 없음) — 차례 = 그 호표 줄 · 대상 하나 =
돈 로직 · M01 자재품목 · 확인된 호표 중 하나 · 입력 이름 = 그 로직 입력 · 배 0 넘음 · 몫 (0, 1] ·
호표 = 만들 층에 확인된 사전조합(없으면 409 「제N호표 먼저 확인」).
호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 ·
상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일} ·
만드는 층 = 부르는 층(일위대가 문서 층). 들어가는 줄(`M02_Hopyo_Match` 의 `들어감`)만:
로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 = 「실무 0.14㎥ × 1 · 규격」, 입력값,
수량 {손 = 엑셀 수량 × 환산}} · 같은 로직 둘째부터 = 그 줄 하나짜리 사전조합으로 감싸 참조
(같은 로직 두 번 금지) · 자재 = {코드, 수량 {손}, 별도계상 = 엑셀 값 없음 「별도계상」 ·
아니면 「직접계산」}(같은 자재 = 수량 더함) · 일위N = 그 호표 사전조합을 먼저 만들어 참조
(순환 = 빠진 줄).
같은 층 · 같은 파일 · 같은 호표 = 있던 것을 다시 씀 · `새로` = 그 키에 다시 만들어 덮음(감싼 것도).
빠진 줄(% · 못 맞춤 · 빼기 · 환산 입력 · 추정) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가.
상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일, 원본 [층, 주인],
확인 {누가, 때}} · 만드는 층 = 부르는 층(일위대가 문서 층).
로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 「실무 0.14㎥ × 1 · 규격」, 입력값,
수량 {손 = 엑셀 수량 × 배}} · 한 줄 로직 여럿 = 같은 별칭 · 같은 로직 둘째부터 = 한 줄
사전조합으로 감싸 참조(같은 로직 두 번 금지) ·
자재 = {코드, 수량 {손}, 별도계상}(같은 자재 = 수량 더함) · 호표 = 확인된 사전조합 참조.
같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음
(`M02_Hopyo_Ledger` · 호표 참조 줄 뺌).
빠진 줄(확인 안 함 · 뺌 · %) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가(참고).
엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`).
"""
from __future__ import annotations
import json
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_Match as match
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
ORIGIN = "실무자료"
WHY = {
"%": "% 줄 — 우리 로직 덧줄이 셈",
"빼기": "뺌(확정)",
"후보": "못 맞춤 — 후보 확정 필요",
"없음": "못 맞춤",
}
LINES_MAX = 500
UNCHECKED = "확인 안 함"
PERCENT = "% 줄 — 우리 로직 덧줄이 셈"
WHOLE = "호표 통째로 대신"
def mark(key: str, no: int, at: int | None = None) -> str:
@@ -42,25 +47,46 @@ def mark(key: str, no: int, at: int | None = None) -> str:
return head if at is None else f"{head} {at + 1}째 줄"
def _found(origin: str) -> tuple[str, str] | None:
"""지금 층 사전조합 가운데 본뜬 = 이 실무 표시 → (키, 판)."""
for path in sorted((store.root() / "precombo").glob("SC-*.json")):
def _origins(folder: Path):
"""층 사전조합 가운데 실무자료 본뜬 → (키, 본뜬, 판)."""
for path in sorted((Path(folder) / "precombo").glob("SC-*.json")):
try:
doc = json.loads(path.read_bytes())
raw = path.read_bytes()
doc = json.loads(raw)
except (OSError, ValueError):
continue
got = doc.get("본뜬") if isinstance(doc, dict) else None
if isinstance(got, dict) and got.get("층") == ORIGIN and got.get("키") == origin:
return path.stem, store.version_of(path.read_bytes())
return None
if isinstance(got, dict) and got.get("층") == ORIGIN:
yield path.stem, got, raw
def _save(doc: dict[str, Any], fresh: bool) -> tuple[dict[str, Any], bool]:
"""(참조, 다시씀) — 있으면 그대로(`fresh` 면 그 키에 덮음) · 없으면 새 키."""
def _same_source(got: dict[str, Any], source: refs.Here | None) -> bool:
"""본뜬 원본 층이 같음 — 원본 없는 옛 것 · `source` None 은 가리지 않음."""
have = got.get("원본")
return source is None or not have or tuple(have) == tuple(source)
def confirmed(folder: str | Path, source: refs.Here | None = None) -> dict[str, str]:
"""층에 확인된 실무 사전조합 `{본뜬 키: SC 키}`(`source` = 파일 층 (층, 주인)으로 거름)."""
return {
got["키"]: key
for key, got, _ in _origins(Path(folder))
if got.get("확인") and _same_source(got, source)
}
def _save(doc: dict[str, Any]) -> tuple[dict[str, Any], bool]:
"""(참조, 다시씀) — 같은 본뜬(키 · 원본)이 있으면 그 키에 덮음 · 없으면 새 키."""
at = refs.here()
have = _found(doc["본뜬"]["키"])
if have and not fresh:
return {"층": at[0], "주인": at[1], "키": have[0]}, True
want = doc["본뜬"]
have = next(
(
(key, store.version_of(raw))
for key, got, raw in _origins(store.root())
if got.get("키") == want["키"] and _same_source(got, tuple(want["원본"]))
),
None,
)
if have:
store.write("precombo", have[0], have[1], doc)
key = have[0]
@@ -74,10 +100,11 @@ def _num(value: Decimal) -> int | float:
return int(value) if value == value.to_integral_value() else float(value)
def _memo(line: dict[str, Any], times: Any) -> str:
def _memo(line: dict[str, Any], one: dict[str, Any]) -> str:
qty = line.get("수량")
spec = str(line.get("규격") or "").strip()
head = f"실무 {qty if qty is not None else ''}{line.get('단위') or ''} × {times}"
head = f"실무 {qty if qty is not None else ''}{line.get('단위') or ''} × {one['배']}"
head += f"(몫 {one['몫']})" if one.get("몫") is not None else ""
return f"{head} · {spec}" if spec else head
@@ -90,96 +117,178 @@ def _missing(line: dict[str, Any], why: str) -> dict[str, Any]:
}
def _bad(text: str, status: int = 400) -> store.StoreError:
return store.StoreError(status, text)
class Builder:
"""한 번 만들기 — 파일 · 층 기억 · 우리 쪽 목록을 한 번 읽고 호표마다 만든 참조를 기억."""
"""한 번 만들기 — 파일 · 우리 쪽 목록 · 확인된 호표를 한 번 읽고 몸을 검사한 뒤 씀."""
def __init__(self, source: Path, folder: Path, key: str, fresh: bool) -> None:
def __init__(self, folder: Path, source: Path, key: str, no: int, who: Any) -> None:
self.folder = Path(folder)
self.doc = hopyo_store.read(source, key)
self.key, self.fresh = key, fresh
self.memo, self.units = match.memories(folder), match.units_of(self.doc)
self.got = match.targets()
self.made: dict[int, dict[str, Any]] = {}
self.missing: dict[int, list[dict[str, Any]]] = {}
self.key, self.no, self.who = key, int(no), who
self.source = refs.where(source)
self.done = confirmed(folder, self.source)
self.known = ledger._known()
self.hopyo = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == self.no), None)
if self.hopyo is None:
raise _bad(f"없는 호표 「제{no}호표」", 404)
def hopyo(self, no: int) -> dict[str, Any]:
found = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == int(no)), None)
if found is None:
raise store.StoreError(404, f"없는 호표 「제{no}호표」")
return found
# ── 재검사 ───────────────────────────────────────────────────────
def _target(self, raw: dict[str, Any], where: str) -> dict[str, Any]:
if raw.get("호표") is None:
return ledger.check_pair(
{k: v for k, v in raw.items() if k != "호표"}, where, self.known
)
if raw.get("로직") or raw.get("자재") or raw.get("입력값"):
raise _bad(f"호표 대상은 호표 · 배만 「{where}」")
child = int(raw["호표"])
if child == self.no:
raise _bad(f"호표가 자기를 담음 「{where}」")
if mark(self.key, child) not in self.done:
raise _bad(f"제{child}호표 먼저 확인", 409)
times, part = ledger.times_of(raw, where)
return {"호표": child, "배": times, "몫": part}
def build(self, no: int, trail: tuple[int, ...] = ()) -> dict[str, Any]:
"""호표 → 사전조합 참조 — 일위N 은 먼저(순환은 빠진 줄)."""
if no in self.made:
return self.made[no]
hopyo = match.match_hopyo(self.hopyo(no), self.memo, self.units, True, self.got)
lines, mats, missing = [], {}, []
used: set[str] = set()
for line in hopyo["줄"]:
got = line["맞춤"]
if not got["들어감"]:
conv = got.get("환산") or {}
why = WHY.get(got["상태"]) or (
"추정 환산 — 확정 필요" if conv.get("갈래") == "추정" else "환산 입력 필요"
)
missing.append(_missing(line, why))
continue
times = got["환산"]["배"]
qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(times))
target = got["대상"]
alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None
memo = _memo(line, times)
if "자재" in target:
code = target["자재"]
def check(self, lines: list[dict[str, Any]], whole: dict[str, Any] | None) -> tuple[dict, list]:
"""몸 재검사 → ({차례: 확인 줄}, 통째 대상) — 틀리면 400 · 하위 호표 409."""
if whole is not None and lines:
raise _bad("통째 · 줄 중 하나만")
if not lines and not (whole or {}).get("대상"):
raise _bad("확인한 줄이 없음")
if len(lines) > LINES_MAX:
raise _bad(f"줄은 {LINES_MAX} 개 안")
have = self.hopyo.get("줄") or []
out: dict[int, dict[str, Any]] = {}
for raw in lines:
at = int(raw.get("차례", -1))
if not 0 <= at < len(have) or at in out:
raise _bad(f"없는 줄 · 겹친 줄 「제{self.no}호표 {at}」")
where = f"제{self.no}호표 {at + 1}째 줄"
targets = raw.get("대상") or []
if bool(raw.get("뺌")) == bool(targets):
raise _bad(f"대상 · 뺌 중 하나만 「{where}」")
if raw.get("뺌") and raw["뺌"] not in ledger.KINDS[1:]:
raise _bad(f"뺌 까닭이 틀림 「{where}」")
if len(targets) > 20:
raise _bad(f"대상은 스물 이하 「{where}」")
out[at] = {"뺌": raw.get("뺌")} if raw.get("뺌") else {
"대상": [self._target(t, where) for t in targets]
} # fmt: skip
pairs = [self._target(t, f"제{self.no}호표 통째") for t in (whole or {}).get("대상") or []]
if any("호표" in p for p in pairs) or len(pairs) > 20:
raise _bad("통째 대상은 로직 · 자재만")
return out, pairs
# ── 쓰기 ─────────────────────────────────────────────────────────
def _origin(self, origin: str, stamp: str) -> dict[str, Any]:
sure = {"누가": self.who, "때": stamp}
file = self.doc.get("파일")
return {
"층": ORIGIN,
"주인": None,
"키": origin,
"파일": file,
"원본": list(self.source),
"확인": sure,
}
def build(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> dict:
stamp = datetime.now().isoformat(timespec="seconds")
hopyo, file = self.hopyo, self.doc.get("파일") or ""
lines, mats, missing, used = [], {}, [], set()
def put(line: dict[str, Any], one: dict[str, Any], alias: str | None) -> None:
qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"]))
memo = _memo(line, one)
if "자재" in one:
apart = "별도계상" if line.get("갈래") == "별도계상" else "직접계산"
have = mats.get(code)
have = mats.get(one["자재"])
hand = qty + Decimal(str(have["수량"]["손"])) if have else qty
mats[code] = {"코드": code, "수량": {"손": _num(hand)}, "별도계상": apart}
continue
if "호표" in target:
child = int(target["호표"])
if child in (*trail, no):
missing.append(_missing(line, "순환 — 호표가 서로 담음"))
continue
ref = self.build(child, (*trail, no))
mats[one["자재"]] = {
"코드": one["자재"],
"수량": {"손": _num(hand)},
"별도계상": apart,
}
return
if "호표" in one:
ref = {
"층": refs.here()[0],
"주인": refs.here()[1],
"키": self.done[mark(self.key, one["호표"])],
}
lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
continue
one = {"로직": target["로직"], "입력값": got.get("입력값") or {}, "메모": memo}
if target["로직"] not in used:
used.add(target["로직"])
lines.append({**one, "별칭": alias, "수량": {"손": _num(qty)}})
continue
return
row = {"로직": one["로직"], "입력값": one["입력값"], "메모": memo}
if one["로직"] not in used:
used.add(one["로직"])
lines.append({**row, "별칭": alias, "수량": {"손": _num(qty)}})
return
at = int(line.get("차례", 0))
wrap = { # 같은 로직 둘째 — 한 줄 사전조합으로 감쌈
"이름": f"{hopyo['이름']} — {line.get('이름')}",
"단위": target.get("단위"),
"단위": line.get("단위") or None,
"구분": ORIGIN,
"상세구분": self.doc.get("파일") or "",
"담은로직": [{**one, "별칭": alias, "수량": {"손": 1}}],
"상세구분": file,
"담은로직": [{**row, "별칭": alias, "수량": {"손": 1}}],
"자재": [],
"본뜬": self._origin(mark(self.key, no, line["차례"])),
"본뜬": self._origin(f"{mark(self.key, self.no, at)} {one['로직']}", stamp),
}
ref, _ = _save(wrap, self.fresh)
ref, _ = _save(wrap)
lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
book: dict[str, dict[str, Any]] = {}
if whole:
for one in whole:
put({"수량": 1, "단위": hopyo.get("단위"), "이름": hopyo.get("이름")}, one, None)
book[ledger.hopyo_key(hopyo)] = self._row(hopyo, "짝", whole)
for at, line in enumerate(hopyo.get("줄") or []):
got = checked.get(at)
if line.get("갈래") == "%":
missing.append(_missing(line, PERCENT))
continue
if whole or got is None:
missing.append(_missing(line, WHOLE if whole else UNCHECKED))
continue
if got.get("뺌"):
missing.append(_missing(line, f"뺌 — {got['뺌']}"))
book[ledger.key(line)] = self._row(line, got["뺌"], [])
continue
alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None
for one in got["대상"]:
put(line, one, alias)
pairs = [p for p in got["대상"] if "호표" not in p]
if pairs:
book[ledger.key(line)] = self._row(line, "짝", pairs)
name = " ".join(x for x in (hopyo["이름"], hopyo.get("규격")) if x)
doc = {
"이름": name,
"단위": hopyo.get("단위") or None,
"구분": ORIGIN,
"상세구분": self.doc.get("파일") or "",
"상세구분": file,
"담은로직": lines,
"자재": list(mats.values()),
"본뜬": self._origin(mark(self.key, no)),
"본뜬": self._origin(mark(self.key, self.no), stamp),
}
ref, again = _save(doc, self.fresh)
self.made[no] = {**ref, "다시씀": again}
self.missing[no] = missing
return self.made[no]
ref, again = _save(doc)
wrote = ledger.write(self.folder, book, self.who, file or None)
return {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}}
def _origin(self, origin: str) -> dict[str, Any]:
return {"층": ORIGIN, "주인": None, "키": origin, "파일": self.doc.get("파일")}
def _row(self, line: dict[str, Any], kind: str, pairs: list[dict[str, Any]]) -> dict[str, Any]:
"""장부 한 줄 — 엑셀조항 = 줄 근거 출처(있으면)."""
origin = {"품명": line.get("이름"), "규격": line.get("규격"), "단위": line.get("단위")}
clauses = (line.get("근거") or {}).get("출처") or []
return {
"원래": {k: str(v or "") for k, v in origin.items()},
"갈래": kind,
"짝": pairs,
"엑셀조항": clauses if isinstance(clauses, list) else [],
}
def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]:
"""만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가."""
"""만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가(참고 · 판정 아님)."""
got = refs.Expand(m01.loaded()[1])({k: ref[k] for k in ("층", "주인", "키")})
base = got.get("기준") or {}
ours = float(base["계"]) if "계" in base else None
@@ -188,17 +297,18 @@ def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]:
return {"엑셀": excel, "우리": ours, "차이": gap, "까닭": got.get("까닭")}
def build(folder: Path, source: Path, key: str, no: int, fresh: bool = False) -> dict[str, Any]:
"""실무 호표(`source` 층 파일) → 지금 층(`folder`) 사전조합 —
`{참조, 이름, 다시씀, 빠진줄, 비교}`(부르는 쪽이 `store.at(folder)`)."""
maker = Builder(source, folder, key, fresh)
made = maker.build(int(no))
hopyo = maker.hopyo(int(no))
ref = {k: made[k] for k in ("층", "주인", "키")}
return {
"참조": ref,
"이름": hopyo.get("이름"),
"다시씀": made["다시씀"],
"빠진줄": maker.missing.get(int(no), []),
"비교": compare(ref, hopyo),
}
def build(
folder: Path,
source: Path,
key: str,
no: int,
lines: list[dict[str, Any]] | None = None,
whole: dict[str, Any] | None = None,
who: Any = None,
) -> dict[str, Any]:
"""실무 호표(`source` 층 파일) + 확인한 줄 → 지금 층(`folder`) 사전조합 ·
별칭 장부 — `{참조, 이름, 다시씀, 빠진줄, 비교, 장부}`(부르는 쪽이 `store.at(folder)`)."""
maker = Builder(folder, source, key, no, who)
checked, pairs = maker.check(list(lines or []), whole)
made = maker.build(checked, pairs)
return {**made, "이름": maker.hopyo.get("이름"), "비교": compare(made["참조"], maker.hopyo)}
+44 -6
View File
@@ -7,13 +7,19 @@
호표 줄 = 「제N호표」 · 호표 1 단위 값 = 금액 열 · 구성 줄 = 호표 칸 빔 · 품명 있음 ·
끝 = 다음 호표.
갈래 = % · 호표(비고 일위N참조) · 단가산출(단산N참조) · 기계(단위 hr) · 별도계상(값 없음) ·
자재(재료비만) · 기타 — 규격은 원문 그대로 둠.
자재(재료비만) · 노임(단위 인 + 우리 인력 이름) · 기타 — 규격은 원문 그대로 둠.
이음(338) = 비고 「단산 · 산근 · 단가산출 N(참조 · 호표)」 → 단산 N · 「일위 · 대가 · 일위대가 N」 →
호표 N(빈칸 무시) · 비고 빈 줄(노무비 · 경비 단가 있음)은 품명 + 단위가 같은 블록이 하나뿐이면
이음(줄 `이음` = 「이름」) · 하이퍼링크 안 읽음.
STmate 꼴(「일위대가표」 · 「단가산출근거」 시트) = `M02_Hopyo_Stmate.read` ·
문서 보안(DRM) 파일 = 400 글.
검산 = 줄 금액 floor(단가 × 수량 × 10) / 10 · 합계 단가 = 셋 합 ·
% 줄 = floor(바탕 × 율 / 100 × 10) / 10 · 호표 비목 = floor(줄 금액 합) · 총괄표 합계 —
어긋남은 글 목록(막지 않음).
행(329-1) = 호표 [제N호표 줄, 마지막 구성 줄] · 구성 줄 한 줄(엑셀 1부터) ·
근거(330-1) = 단산N참조 줄에 「단가산출」 블록 `{출처, 직종, 행}`(`M02_Hopyo_Basis.read`) ·
`단산` = 블록마다 `{번, 이름, 단위, 행}`.
`단산` = 블록마다 `{번, 이름, 단위, 행, 출처, 직종, 글}`(338 · 글 = 머리 뺀 몸 줄) ·
`꼴` = 「엑셀꼴」.
"""
from __future__ import annotations
@@ -24,6 +30,7 @@ from decimal import ROUND_FLOOR, Decimal, InvalidOperation
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate
SHEET = "일위대가"
SUMMARY = "일위대가총괄표"
@@ -31,7 +38,9 @@ COSTS = ("노무비", "재료비", "경비")
HEADS = ("호표", "품명", "규격", "수량", "단위", "비고")
NO_SHEET = "일위대가 시트 없음"
_HOPYO = re.compile(r"제\s*(\d+)\s*호표")
_REF = re.compile(r"^(단산|일위)\s*(\d+)\s*참조$")
_REF = re.compile(r"^(?:(단산|산근|단가산출)|(일위대가|일위|대가))(\d+)(?:참조|호표)?$")
DRM = "문서 보안(DRM)으로 잠긴 파일 — 보안을 푼 사본으로 저장해 올림"
_OLE = b"\xd0\xcf\x11\xe0"
_MACHINE_UNITS = ("hr", "시간", "h")
@@ -76,11 +85,15 @@ def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]:
wanted = (SHEET, SUMMARY, hopyo_basis.SHEET)
if not data:
raise ExcelError("빈 파일")
if b"DRMONE" in data[:256] and data[:4] not in (b"PK\x03\x04", _OLE):
raise ExcelError(DRM)
try:
if data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx"):
import openpyxl
book = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
if hopyo_stmate.is_stmate([t.strip() for t in book.sheetnames]):
wanted = hopyo_stmate.SHEETS
out = {
ws.title.strip(): [list(row) for row in ws.iter_rows(values_only=True)]
for ws in book.worksheets
@@ -142,6 +155,8 @@ def kind_of(line: dict[str, Any]) -> str:
return "단가산출"
if unit in _MACHINE_UNITS:
return "기계"
if unit == "인" and _flat(line.get("이름")) in hopyo_basis.trade_names():
return "노임"
if not any(price.get(k) for k in ("계", *COSTS)):
return "별도계상"
if price.get("재료비") and not price.get("노무비") and not price.get("경비"):
@@ -207,10 +222,28 @@ def _summary(rows: list[list[Any]] | None) -> dict[int, Decimal]:
return out
def _by_name(raw: list[list[dict[str, Any]]], blocks: dict[int, dict[str, Any]]) -> None:
"""비고 빈 줄(노무비 · 경비 단가 있음) → 품명 + 단위가 같은 블록이 하나뿐이면 단산 이음."""
index: dict[tuple[str, str], list[int]] = {}
for no, block in blocks.items():
index.setdefault((_flat(block["이름"]), _flat(block["단위"]).lower()), []).append(no)
for line in (one for lines in raw for one in lines):
if line["참조"] or line["비고"] or line["갈래"] == "%":
continue
if not (line["단가"].get("노무비") or line["단가"].get("경비")):
continue
hit = index.get((_flat(line["이름"]), _flat(line["단위"]).lower())) or []
if len(hit) == 1:
line["참조"], line["이음"] = {"종류": "단산", "번": hit[0]}, "이름"
line["갈래"] = kind_of(line)
# ── 읽기 ──────────────────────────────────────────────────────────────
def read_book(name: str, data: bytes) -> dict[str, Any]:
"""엑셀 → `{시트, 호표: [...], 검산: {어긋남: [글 …], 총괄표: 맞춘 수}}` — 값은 JSON 수."""
sheets = _sheets(name, data)
if hopyo_stmate.is_stmate(sheets):
return hopyo_stmate.read(sheets)
rows = sheets.get(SHEET)
if rows is None:
raise ExcelError(NO_SHEET)
@@ -237,7 +270,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
if head or not name_ or not hopyos:
continue
note = _text(_cell(row, cols.get("비고")))
ref = _REF.match(note.replace(" ", ""))
ref = _REF.match(_flat(note))
line = {
"차례": len(raw[-1]),
"이름": name_,
@@ -247,7 +280,9 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
"단가": _money(row, cols, 0),
"금액": _money(row, cols, 1),
"비고": note,
"참조": {"종류": ref.group(1), "번": int(ref.group(2))} if ref else None,
"참조": {"종류": "단산" if ref.group(1) else "일위", "번": int(ref.group(3))}
if ref
else None,
}
line["갈래"] = kind_of(line)
line["행"] = hopyos[-1]["행"][1] = at
@@ -255,6 +290,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
if not hopyos:
raise ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음")
blocks = hopyo_basis.read(sheets.get(hopyo_basis.SHEET))
_by_name(raw, blocks)
for line in (one for lines in raw for one in lines):
ref = line["참조"] or {}
if ref.get("종류") == "단산" and (block := blocks.get(ref["번"])):
@@ -270,8 +306,10 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
{**_json(hopyo), "줄": [_json(line) for line in lines]}
for hopyo, lines in zip(hopyos, raw, strict=True)
]
basis = [{k: b[k] for k in ("번", "이름", "단위", "행")} for b in blocks.values()]
keep = ("번", "이름", "단위", "행", "출처", "직종", "글")
basis = [{k: b[k] for k in keep} for b in blocks.values()]
return {
"꼴": "엑셀꼴",
"시트": SHEET,
"호표": out,
"단산": basis,
+6 -4
View File
@@ -1,11 +1,12 @@
"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트 →
"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트
(STmate = 「일위대가표」 · 「단가산출근거」 · 338) →
A00 산출근거 스프레드시트 Workbook(`A00_Common/spreadsheet/spreadsheet_types.ts` 꼴 ·
읽기 전용 보기).
xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) ·
행 높이(÷20 pt · 시트 기본과 다른 것만) · 서식 표(글꼴 · 굵게 · 맞춤 · 줄바꿈 · 테두리 · 채움 ·
숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행).
xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지.
xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지 · STmate 숨은 칸 글(「_x0005_`BDCOD…」) 뺌.
결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` — 행 = 엑셀 1부터 [시작, 끝].
"""
@@ -18,8 +19,9 @@ from openpyxl.utils import get_column_letter
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate
SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET)
SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET, hopyo_stmate.SHEET, hopyo_stmate.CALC)
HEAD_ROWS = 4
_HALIGN = {1: "left", 2: "center", 3: "right", 6: "centerContinuous", 7: "distributed"}
_VALIGN = {0: "top", 1: "center"}
@@ -192,7 +194,7 @@ def _xlsx(name: str, data: bytes) -> dict[str, Any]:
}
for r, row in enumerate(rows)
for c, v in enumerate(row[:width])
if v not in (None, "")
if v not in (None, "") and not str(v).startswith("_x000") # STmate 숨은 칸 글
}
sheets.append({"id": f"s{at}", "이름": title, "칸": cells, "틀고정": {"행": HEAD_ROWS}})
return {"서식": [{}], "시트": sheets}
+267
View File
@@ -0,0 +1,267 @@
"""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 and values else set()
if unknown:
raise _bad(f"모르는 입력 {sorted(unknown)} 「{where}」")
times, part = times_of(raw, where)
head = {"로직": code} if code else {"자재": mat}
return {
**head,
"입력값": values,
"배": times,
"몫": part,
}
def times_of(raw: dict[str, Any], where: str) -> tuple[int | float, int | float | None]:
"""(배, 몫) 재검사 — 배 0 넘는 수 · 몫 없음 또는 (0, 1]."""
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}」")
return _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)}
+264
View File
@@ -0,0 +1,264 @@
"""M02 일위대가 실무자료 STmate 꼴 읽기(PLAN 338-2b) — 「일위대가표」 · 「단가산출근거」 시트 +
목록표(노무비 · 중기 · 재료비 · 경비 · 단가산출근거) → 엑셀꼴과 같은 HP 꼴
(`M02_Hopyo_Excel.read_book`).
일위대가표 = A 열 「제N호표」 줄(B~Z 열 「BDCOD|B…」 = 호표 코드) → 다음 줄 A 이름 · B 규격 ·
D 단위 → 구성 줄(A 이름 · B 규격 · C 수량 · D 단위 · E 계 · G 노무비 · I 재료비 · K 경비 단가 ·
다음 열 금액 · M 비고) → 「합계」 줄에서 끝.
줄 코드 = M~Z 열에서 `_CODE` 꼴 첫 것(판마다 열 수가 달라도 읽힘 · 하이퍼링크 안 읽음) ·
코드 없는 줄 = 호표 `근거글`.
갈래 = D 단가산출(참조 단산 · 번 = 코드 숫자) · B 호표(참조 일위 · 번 = 호표 코드 표) · L 노임 ·
X 기계 · M 자재(비고 「별산」 · 규격 「별도계상」 · 값 없음 = 별도계상) · S · W 별도계상 ·
PR · 단위 % = % · 그 밖 기타.
단가산출근거 = 「BDCOD|D…」 머리 → 다음 머리 · 「총계」 앞까지 한 블록 · 글 = B 열(머리 이름 줄 ·
소계 · 계 뺌) · 자원 = M 열 「X00000_n」 줄(품 = H 열 · 이름 · 규격 = X 열 「이름 / 규격」 ·
비면 목록표 · 기계 셋 줄(노무비 · 재료비 · 경비)은 하나로 · 절 = 그 앞 번호 절 제목) ·
머리 이름 · 규격 · 단위 = 단가산출근거목록표 · 출처 = 블록 글 `Basis.sources` ·
직종 = 자원 L 이름(우리 인력 · 옛이름 = 지금 이름).
검산 안 함 — `검산 {어긋남: [], 총괄표: 0}`.
"""
from __future__ import annotations
import re
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel # 서로 부름 — 부를 때만 씀
SHEET = "일위대가표"
CALC = "단가산출근거"
LISTS = ("단가산출근거목록표", "노무비목록표", "중기목록표", "재료비목록표", "경비목록표")
SHEETS = (SHEET, CALC, *LISTS)
_CODE = re.compile(r"^(?:COD\|)?([A-Z]{1,2}\d{5}|PR[A-Z]?\d+|[TC]\d+)(?:_\d+)?(?: →)?$")
_META = re.compile(r"(?<![A-Z])COD\|([A-Z]{1,2}\d{5})") # 숨은 칸 「`COD|L00001」
_HEAD_B = re.compile(r"BDCOD\|(B\d{5})")
_HEAD_D = re.compile(r"BDCOD\|(D\d{5})")
_RES = re.compile(r"^([LMXS])(\d{5})_\d+$")
_RES_A = re.compile(r"^([LMXS]\d{5})[LMS]$") # A 열 「L00002L」
_RES_G = re.compile(r"&([LMXS]\d{5})[LMS]&") # G 열 식 「&L00002L&」
_SECTION = re.compile(r"^\s*(?:\d+\s*[.)]|[①-⑩])\s*(.*\S)")
_COLS = {"합계": 4, "노무비": 6, "재료비": 8, "경비": 10}
NOTE, CODE_COLS, RES_COL, QTY_COL, NAME_COL = 12, range(12, 26), 12, 7, 23
_KIND = {"D": "단가산출", "B": "호표", "L": "노임", "X": "기계", "S": "별도계상", "W": "별도계상"}
def is_stmate(names: Any) -> bool:
return SHEET in names and CALC in names
def _cell(row: list[Any], col: int) -> Any:
return row[col] if col < len(row) else None
def _s(value: Any) -> str:
return hopyo_excel._text(value)
def _flat(value: Any) -> str:
return re.sub(r"\s+", "", str(value or ""))
def _code(row: list[Any], cols: Any = CODE_COLS) -> str | None:
for col in cols:
hit = _CODE.match(_s(_cell(row, col)))
if hit:
return hit.group(1)
return None
def _lists(sheets: dict[str, list[list[Any]]]) -> dict[str, dict[str, str]]:
"""목록표 → {코드: {이름, 규격, 단위}} — B 이름 · C 규격 · D 단위 · 코드 = 숨은 칸
「COD|…」 · 없으면 코드 꼴 첫 칸(판마다 A 열 · 뒤 열)."""
out: dict[str, dict[str, str]] = {}
for title in LISTS:
for row in sheets.get(title) or []:
meta = _META.search(" ".join(_s(v) for v in row))
code = meta.group(1) if meta else _code(row, range(len(row)))
if code and _s(_cell(row, 1)):
out[code] = {
"이름": _s(row[1]),
"규격": _s(_cell(row, 2)),
"단위": _s(_cell(row, 3)),
}
return out
# ── 단가산출근거 블록 ──────────────────────────────────────────────────
def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str, dict[str, Any]]:
"""{D 코드: {번, 코드, 이름, 규격, 단위, 행, 출처, 직종, 글, 자원}}."""
names = hopyo_basis.trade_names()
out: dict[str, dict[str, Any]] = {}
block: dict[str, Any] | None = None
title = ""
for at, row in enumerate(rows, 1):
head = next(
(hit for v in row[:8] if (hit := _HEAD_D.search(_s(v)))), None
) # 머리 = G 열(판마다 B 열 글이 다름)
if head:
code = head.group(1)
info = table.get(code) or {}
block = out.setdefault(
code,
{
"번": int(code[1:]),
"코드": code,
"이름": info.get("이름", ""),
"규격": info.get("규격", ""),
"단위": info.get("단위", ""),
"행": [at, at],
"글": [],
"자원": [],
},
)
title = ""
continue
if block is None:
continue
first = _flat(_cell(row, 0))
if first.startswith("총계"):
block["행"][1] = at
block = None
continue
text = _s(_cell(row, 1))
res = _RES.match(_s(_cell(row, RES_COL)))
code = res.group(1) + res.group(2) if res else None
if not code:
alt = _RES_A.match(first) or _RES_G.search(_s(_cell(row, 6)))
code = alt.group(1) if alt else None
if not text and not code:
continue
block["행"][1] = at
if _s(_cell(row, RES_COL)) == block["코드"]: # 머리 이름 줄 「이름 규격 / 단위」
if not block["이름"]:
name, _, unit = text.rpartition("/")
block["이름"], block["단위"] = name.strip() or text, unit.strip()
continue
if text and _flat(text) not in ("소계", "계"):
if len(block["글"]) < hopyo_basis.GLINES:
block["글"].append(text[: hopyo_basis.GCHARS])
if (sec := _SECTION.match(text)) and not re.match(r"^\s*\d+\.\d", text):
title = sec.group(1).strip()
if not code:
continue
last = block["자원"][-1] if block["자원"] else None
if code[0] == "X" and last and last["코드"] == code and last["절"] == title:
continue # 기계 노무비 · 재료비 · 경비 셋 줄 = 하나
name, _, spec = _s(_cell(row, NAME_COL)).partition("/")
info = table.get(code) or {}
qty = hopyo_excel._num(_cell(row, QTY_COL))
block["자원"].append(
{
"갈래": code[0],
"코드": code,
"이름": name.strip() or info.get("이름", ""),
"규격": spec.strip() if name.strip() else info.get("규격", ""),
"품": float(qty) if qty is not None else None,
"절": title,
}
)
for block in out.values():
block["출처"] = hopyo_basis.sources(" ".join(block["글"]))
trades = [names.get(_flat(r["이름"])) for r in block["자원"] if r["갈래"] == "L"]
block["직종"] = list(dict.fromkeys(t for t in trades if t))
return out
# ── 일위대가표 ────────────────────────────────────────────────────────
def _kind(code: str, line: dict[str, Any]) -> str:
if code.startswith("PR") or _flat(line["단위"]) == "%":
return "%"
if code[0] == "M":
apart = line["비고"].startswith("별산") or line["규격"] == "별도계상"
return "별도계상" if apart or not any(line["단가"].values()) else "자재"
return _KIND.get(code[0], "기타")
def _hopyos(rows: list[list[Any]]) -> tuple[list[dict[str, Any]], list[list[dict[str, Any]]]]:
hopyos: list[dict[str, Any]] = []
raw: list[list[dict[str, Any]]] = []
want_head = done = False
for at, row in enumerate(rows, 1):
first = _s(_cell(row, 0))
hit = hopyo_excel._HOPYO.fullmatch(_flat(first))
if hit:
code = next((m.group(1) for v in row[1:26] if (m := _HEAD_B.search(_s(v)))), None)
hopyos.append({"번": int(hit.group(1)), "코드": code, "행": [at, at], "근거글": []})
raw.append([])
want_head, done = True, False
continue
if not hopyos or done:
continue
hopyo = hopyos[-1]
if want_head:
hopyo.update(이름=first, 규격=_s(_cell(row, 1)), 단위=_s(_cell(row, 3)))
hopyo["금액"] = {}
want_head = False
continue
if _flat(first).startswith("합계") or _flat(first) == "계":
hopyo["금액"] = hopyo_excel._money(row, _COLS, 1)
done = True # 끝 — 다음 호표까지 줄 안 받음
continue
if not first:
continue
code = _code(row)
hopyo["행"][1] = at
if not code:
if re.search(r"[0-9A-Za-z가-힣]", first):
hopyo["근거글"].append(f"{first} {_s(_cell(row, 1))}".strip())
continue
if code[0] in "TC" and code[1].isdigit():
continue # 계 · 소계 · 계약단가
line = {
"차례": len(raw[-1]),
"이름": first,
"규격": _s(_cell(row, 1)),
"수량": hopyo_excel._num(_cell(row, 2)),
"단위": _s(_cell(row, 3)),
"단가": hopyo_excel._money(row, _COLS, 0),
"금액": hopyo_excel._money(row, _COLS, 1),
"비고": _s(_cell(row, NOTE)),
"코드": code,
"행": at,
}
line["갈래"] = _kind(code, line)
line["참조"] = (
{"종류": "단산", "번": int(code[1:]), "코드": code}
if code[0] == "D"
else {"종류": "일위", "번": None, "코드": code}
if code[0] == "B"
else None
)
raw[-1].append(line)
return hopyos, raw
def read(sheets: dict[str, list[list[Any]]]) -> dict[str, Any]:
"""STmate 시트 → HP 꼴 `{꼴, 시트, 호표, 단산, 검산}`(값은 JSON 수)."""
hopyos, raw = _hopyos(sheets.get(SHEET) or [])
if not hopyos:
raise hopyo_excel.ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음")
table = _lists(sheets)
blocks = _blocks(sheets.get(CALC) or [], table)
by_code = {h["코드"]: h["번"] for h in hopyos if h["코드"]}
for line in (one for lines in raw for one in lines):
ref = line["참조"] or {}
if ref.get("종류") == "일위":
ref["번"] = by_code.get(ref["코드"])
elif ref.get("종류") == "단산" and (block := blocks.get(ref["코드"])):
line["근거"] = {k: block[k] for k in ("출처", "직종", "행")}
out = []
for hopyo, lines in zip(hopyos, raw, strict=True):
for key in ("이름", "규격", "단위"):
hopyo.setdefault(key, "")
if not hopyo["근거글"]:
del hopyo["근거글"]
out.append({**hopyo_excel._json(hopyo), "줄": [hopyo_excel._json(x) for x in lines]})
keep = ("번", "코드", "이름", "규격", "단위", "행", "출처", "직종", "글", "자원")
return {
"꼴": "STmate",
"시트": SHEET,
"호표": out,
"단산": [{k: b[k] for k in keep} for b in blocks.values()],
"검산": {"어긋남": [], "총괄표": 0},
}
+114 -30
View File
@@ -4,23 +4,28 @@
여기는 저장하지 않는 셈만 + 서고(단가승격 · 사전조합 · 50-3) 새로 · 복사(키는 서버가 줌 · 고침 ·
지우기는 양식 길 그대로) + 개인 자재 목록 · 추가(148-2 · 개인 층만 · 품번은 서버) +
사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기) +
일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기).
일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기) ·
338 = 확인한 줄만 만들기(몸 없음 400 · 옛 즉시 확정 길 없앰) · 별칭 장부 보기 · 고치기 ·
올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`).
층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌).
⚠ 등록은 `main.py`(로그인만).
"""
from __future__ import annotations
import importlib
import inspect
from pathlib import Path
from typing import Any, Literal
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict, Field
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid
from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Labor_Doc as labor
@@ -168,10 +173,27 @@ def new_material(
return _call(folder, own.add, folder, body)
# ── 일위대가 실무자료(324) ─────────────────────────────────────────────
# ── 일위대가 실무자료(324 · 338) ───────────────────────────────────────
HOPYO_MAX_BYTES = 20 * 1024 * 1024
def _later(module: str, name: str):
"""맞춤 엔진(가 창) 함수 — 아직 없으면 None(올리기 · 시동은 그대로)."""
try:
got = importlib.import_module(f"M02_MasterTemplete.{module}")
except ImportError:
return None
return getattr(got, name, None)
if (_warm := _later("M02_Hopyo_Ours", "warm")) is not None:
_warm() # 시동 — 우리 지문 캐시(디스크) 읽어 둠
def _who(session: Session) -> dict[str, Any]:
return {"id": session.get("user_id"), "이름": session.get("name")}
def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
try:
parsed = hopyo_excel.read_book(name, data)
@@ -181,7 +203,10 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
grid = hopyo_grid.read(name, data, parsed)
except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404)
grid = None
return hopyo_store.save(store.root(), name, parsed, who, grid)
row = hopyo_store.save(store.root(), name, parsed, who, grid)
if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None:
ensure(store.root(), row["키"]) # 맞춤 뒤 셈(일감 줄 · 화면 안 기다림)
return row
@router.get("/layers/{layer}/hopyo")
@@ -198,9 +223,20 @@ def hopyo_picks(
layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
"""[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 파일의 호표마다
`{층, 주인, 키, 파일, 번, 이름, 규격, 단위, 맞춤}`(맞춤 n/m = 이 층 기억)."""
`{층, 주인, 키, 파일, 번, 이름, 규격, 단위, 맞춤, 확인}`(확인 = 이 층에 확인된 사전조합)."""
folder = _root(session, layer, owner)
return {"줄": _call(folder, hopyo_match.picks, folder)}
return {"줄": _call(folder, _picks, folder)}
def _picks(folder: Path) -> list[dict[str, Any]]:
rows, here, done = hopyo_match.picks(folder), refs.where(folder), {}
for row in rows:
src = (row["층"], row.get("주인"))
if src not in done:
done[src] = hopyo_build.confirmed(folder, src)
key = done[src].get(hopyo_build.mark(row["키"], row["번"]))
row["확인"] = {"층": here[0], "주인": here[1], "키": key} if key else None
return rows
@router.post("/layers/{layer}/hopyo")
@@ -217,8 +253,7 @@ def hopyo_upload(
if len(data) > HOPYO_MAX_BYTES:
raise HTTPException(status_code=400, detail="파일이 너무 큼(20 MB 넘음)")
name = Path(file.filename or "실무자료").name[:120]
who = {"id": session.get("user_id"), "이름": session.get("name")}
return _call(folder, _upload, name, data, who)
return _call(folder, _upload, name, data, _who(session))
@router.get("/layers/{layer}/hopyo/{key}")
@@ -246,11 +281,17 @@ def hopyo_one(
key: str,
no: int,
owner: str | None = None,
to: Layer | None = None,
to_owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""호표 하나 — 줄마다 맞춤(상태 · 대상 · 입력값 · 환산 · 들어감 · 후보 5)."""
"""호표 하나 — 줄마다 맞춤(상태 · 체크 제안 · 대상 · 후보 5) · `to` = 만들 층(없으면 파일 층 ·
호표 참조 줄 체크 · 만든 · 맞춤 엔진이 `to` 를 받을 때만)."""
folder = _root(session, layer, owner)
return _call(folder, hopyo_match.view, folder, key, no)
if to is None or "to" not in inspect.signature(hopyo_match.view).parameters:
return _call(folder, hopyo_match.view, folder, key, no)
target = _root(session, to, to_owner)
return _call(folder, lambda: hopyo_match.view(folder, key, no, to=target))
class GuessBody(BaseModel):
@@ -274,33 +315,44 @@ def hopyo_guess(
return _call(folder, hopyo_match.guess, folder, key, no, body.차례, body.대상)
class MemoryBody(BaseModel):
"""`줄` = {줄 열쇠: {로직|자재, 입력값, 환산} | {빼기: true} | null(지움)}."""
줄: dict[str, Any]
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid") # 금액 · 품 칸 = 400(서버가 셈)
@router.post("/layers/{layer}/hopyo-memory")
def hopyo_memory(
layer: Layer,
body: MemoryBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""맞춤 확정 · 빼기 → 층 기억(층 쓰기) — 다음부터 같은 줄은 자동."""
folder = _root(session, layer, owner, write=True)
return _call(folder, hopyo_match.confirm, folder, body.줄)
class BuildTarget(_Strict):
"""대상 하나 — 로직 · 자재 · 호표 중 하나 · 배 = 우리 수량 ÷ 엑셀 수량 · 몫 = 섞인 줄 몫."""
로직: str | None = None
자재: str | None = None
호표: int | None = None
입력값: dict[str, Any] = Field(default_factory=dict)
배: float = 1
몫: float | None = None
class BuildBody(BaseModel):
"""실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` ·
`새로` = 있던 것을 덮음."""
class BuildLine(_Strict):
"""확인한 줄 — `차례` = 호표 안 줄(0부터) · `대상` 목록 또는 `뺌` 까닭."""
차례: int
대상: list[BuildTarget] = Field(default_factory=list)
뺌: Literal["우리에 없음", "안 씀"] | None = None
class BuildWhole(_Strict):
대상: list[BuildTarget]
class BuildBody(_Strict):
"""실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` + 확인한 `줄` ·
직접 줄 호표 = `통째` · 같은 본뜬은 늘 덮음(`새로` = 옛 화면 호환 · 안 씀)."""
층: Layer
주인: str | None = None
키: str
번: int
새로: bool = False
줄: list[BuildLine] = Field(default_factory=list, max_length=500)
통째: BuildWhole | None = None
@router.post("/layers/{layer}/hopyo-build")
@@ -310,11 +362,43 @@ def hopyo_make(
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""실무 호표 → 우리 일위대가 사전조합(이 층 쓰기 · 파일 층 읽기) —
`{참조, 이름, 다시씀, 빠진줄, 비교}`."""
"""[확인하고 사전조합 만들기] — 확인한 줄만(이 층 쓰기 · 파일 층 읽기) · 별칭 장부 씀 →
`{참조, 이름, 다시씀, 빠진줄, 비교, 장부}` · 몸 없음 400 · 하위 호표 확인 전 409."""
folder = _root(session, layer, owner, write=True)
source = _root(session, body.층, body.주인)
return _call(folder, hopyo_build.build, folder, source, body.키, body.번, body.새로)
lines = [one.model_dump(exclude_none=True) for one in body.줄]
whole = body.통째.model_dump(exclude_none=True) if body.통째 else None
args = (folder, source, body.키, body.번, lines, whole, _who(session))
return _call(folder, hopyo_build.build, *args)
@router.get("/layers/{layer}/hopyo-ledger")
def hopyo_ledger_read(
layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
"""별칭 장부 — 이 층 + 위 층 줄 `{열쇠, 원래, 갈래, 짝, 누가, 때, 처음파일, 층, 주인, 고침}`
(고침 = 이 층 · 쓰기 권한)."""
folder = _root(session, layer, owner)
writable = access.grant(session, layer, owner).write
return {"줄": _call(folder, hopyo_ledger.rows, folder, writable)}
class LedgerBody(_Strict):
"""`줄` = {열쇠: 장부 줄 | null(지움)} — 이 층만 · 서버가 짝을 다시 검사."""
줄: dict[str, Any]
@router.put("/layers/{layer}/hopyo-ledger")
def hopyo_ledger_write(
layer: Layer,
body: LedgerBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""별칭 장부 [저장] — 고친 줄 · 지운 줄(층 쓰기) → `{씀, 줄}`."""
folder = _root(session, layer, owner, write=True)
return _call(folder, hopyo_ledger.put, folder, body.줄, _who(session))
@router.delete("/layers/{layer}/hopyo/{key}")