merge(M02): origin/dev(39 통합) 받음 — G1 · G2 지움과 부딪힌 곳 풂

- 남의 새 줄(labor · 일위대가번 SU · 실무문서 · 「전체 항목」 · 수평선 · 알약 켬끔 · 복사해 가져오기)은 살리고 material · 자재조합 · 재료번 · matset 줄만 뺌
- dev 쪽 새 시험(test_m02_item_labor · test_m02_card_sheet_edit)의 재료산출 · 자재조합 자리 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
This commit is contained in:
2026-10-04 19:22:22 +09:00
co-authored by Claude Opus 5.5
104 changed files with 7025 additions and 2214 deletions
+69 -19
View File
@@ -64,11 +64,12 @@ IN_USE = {
# (종류, 문서 번호 칸, 머리 글자, 시트 이름) — 시트 이름이 None 이면 구조물 도면
PARTS = (
("structure", "도번", "SD", None),
("labor", "일위대가번", "SU", None), # 일위대가 — `M02_Labor_Doc`(39장)
("area", "면적번", "SA", "면적산출"),
)
_KEY = {kind: key for kind, key, _, _ in PARTS}
SHEETS = ("area",) # 통합문서 종류 — [저장] 때 서버가 계산값을 새로 적음
_CODE = re.compile(r"^S[DA](\d{8}|-\d{6})$")
_CODE = re.compile(r"^S[DAU](\d{8}|-\d{6})$")
_OLD_CODE = re.compile(r"^S[DCA](\d{6})$") # PLAN 12장 6자리 — 끝에 00 을 붙여 읽음
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
# 도각 없는 빈 CAD 문서의 기본 도면층 — 없으면 B07 CAD 가 못 엶("Cannot read properties
@@ -153,8 +154,9 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 면적산출을 번호와 같이
만들고 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음).
통합문서(area)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음
(브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘.
번호 칸(도번 · 면적번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음).
(브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘 · 일위대가(labor)는
`M02_Labor_Doc.server` 가 정리 · `계산값` 을 새로 적음(면적산출 저장 때 그 열 일위대가도).
번호 칸(도번 · 일위대가번 · 면적번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음).
표 열의 `본뜬열`(분류 더하기로 복사한 새 열)은 적지 않음 — 집계표면 아직 파일이 없는 새 열이
원본 열의 일위대가 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로).
`screen` = 화면 표 [저장](PLAN 26-7e) — 있던 항목의 경로 · 층 이름 · 빈 폴더는 저장본 그대로
@@ -164,8 +166,7 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
origins = _take_origins(doc) if kind == "table" else {}
if kind == "table" and name == STRUCTURE_TABLE:
doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기)
if kind in SHEETS:
doc = _server_values(doc)
doc = _server(kind, doc)
with _LOCK:
have = version_of(path.read_bytes()) if path.is_file() else ""
if have != (version or ""):
@@ -177,10 +178,13 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
doc = {**doc, _KEY[kind]: number} if number else doc
atomic_write_json(path, doc)
info = _info(kind, path)
if kind == "area":
_refresh_labor(name) # 면적 칸 값이 바뀌면 일위대가 계산값(목록 합계)도
if kind == "table" and name == STRUCTURE_TABLE:
for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦
for part in _KEY:
_ensure(part, column, source)
_refresh_labor(column) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시
_copy_inputs(source, column)
backfill_missing_structures() # 지우기 앞 — 같은 저장의 새 열이 지운 번호를 안 받게
prune_removed_structures()
@@ -280,6 +284,17 @@ def _retired() -> list[str]:
def _blank(kind: str, column: str) -> dict[str, Any]:
"""빈 문서(번호는 부르는 쪽이 붙임) — 구조물 도면 = 도각 없음 · 기본 도면층 하나 /
면적산출 = 진짜 빈 시트 하나(식 없어 `계산값` 없음 · 첫 [저장] 때 서버가 적음)."""
if kind == "labor":
return {
"종류": "일위대가",
"판": 1,
"열": column,
"단위": None,
"본뜬": None,
"담은로직": [],
"자재": [],
"계산값": {},
}
if kind == "structure":
return {
"양식": "구조물도면",
@@ -309,9 +324,7 @@ def _ensure(kind: str, column: str, source: str | None = None) -> bool:
base = _blank(kind, column)
if source and _path(kind, source).is_file():
got = json.loads(_path(kind, source).read_bytes())
base = {**{k: v for k, v in got.items() if k != "계산값"}, "열": column}
if kind in SHEETS:
base = _server_values(base)
base = _server(kind, {**{k: v for k, v in got.items() if k != "계산값"}, "열": column})
with _LOCK:
if path.is_file():
return False
@@ -321,7 +334,7 @@ def _ensure(kind: str, column: str, source: str | None = None) -> bool:
def _take_origins(doc: Any) -> dict[str, str]:
"""표 열의 `본뜬열` 을 떼어 냄 → `{새 열: 원본 열}` (구조물 파일이 아직 없는 열만) ·
그 열에 일위대가 키가 비었으면 원본 열 것을 받음."""
일위대가는 원본 열 문서를 새 번호로 복사(`_ensure` · 39-1b — 조합 키는 안 받음)."""
cols = (
[c for c in (doc.get("열") or []) if isinstance(c, dict)] if isinstance(doc, dict) else []
)
@@ -334,34 +347,47 @@ def _take_origins(doc: Any) -> dict[str, str]:
if (root() / "structure" / f"{col['id']}.json").is_file():
continue
out[col["id"]] = source
col["일위대가"] = col.get("일위대가") or by_id[source].get("일위대가")
return out
def create_structure(column: str) -> dict[str, Any]:
"""구조물 열 하나의 구조물 도면 새로 — 도번 SD + 새 일련(`-` + 6자리) · 이미 있으면 409.
면적산출 파일이 없으면 빈 통합문서도 같이 만듦(같은 뒷자리)."""
일위대가 · 면적산출 파일이 없으면 빈 문서도 같이 만듦(같은 뒷자리)."""
if column not in _column_ids():
raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 구조물 열 「{column}」")
if not _ensure("structure", column):
raise StoreError(409, f"이미 있는 구조물 도면 「{column}」")
for kind in SHEETS:
_ensure(kind, column)
for kind in _KEY:
_ensure(kind, column) # 도면은 위에서 만듦 · 있는 것은 그대로
return read("structure", column)
def copy_from(kind: str, column: str, source: str) -> dict[str, Any]:
def copy_from(
kind: str,
column: str,
source: str,
source_root: Path | None = None,
body: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""다른 열(`source`) 내용을 받는 열(`column`)로 가져옴 — 번호 · 열은 받는 쪽 그대로(두 열은
따로 움직임) · 통합문서는 알약 `연결` 도 같이 오고 계산값은 서버가 다시 풂."""
따로 움직임) · 통합문서는 알약 `연결` 도 같이 오고 계산값은 서버가 다시 풂 ·
`source_root` = 원본 층 폴더(없으면 지금 층 · 권한은 부르는 쪽) · `body` = 원본 문서를 바로 줌
(실무문서 조합 → 일위대가 · 39-1b)."""
if kind not in _KEY:
raise StoreError(404, f"없는 종류 「{kind}」")
target = read(kind, column)["문서"]
doc = {**read(kind, source)["문서"], **{k: target.get(k) for k in ("열", _KEY[kind])}}
if kind in SHEETS:
doc = _server_values({k: v for k, v in doc.items() if k != "계산값"})
if body is None and source_root is not None:
with at(source_root):
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 != "계산값"})
with _LOCK:
atomic_write_json(_path(kind, column), doc)
return _info(kind, _path(kind, column))
info = _info(kind, _path(kind, column))
if kind == "area":
_refresh_labor(column)
return info
def _copy_inputs(source: str, column: str) -> None:
@@ -454,6 +480,30 @@ def renumber() -> dict[str, list[str]]:
return {"옮김": moved, "지움": gone, "바꿈": changed, "새로": made}
def _server(kind: str, doc: Any) -> Any:
"""서버가 적는 값 — 통합문서 계산값(5장 ②) · 일위대가 정리 · 계산값(`M02_Labor_Doc`)."""
if kind in SHEETS:
return _server_values(doc)
if kind == "labor":
from M02_MasterTemplete import M02_Labor_Doc as labor
return labor.server(doc)
return doc
def _refresh_labor(column: str) -> None:
"""그 열 일위대가 `계산값` 을 지금 면적 값으로 다시 적음 — 문서가 틀어졌으면 그대로 둠."""
path = _path("labor", column)
if not path.is_file():
return
try:
doc = _server("labor", json.loads(path.read_bytes()))
except StoreError:
return
with _LOCK:
atomic_write_json(path, doc)
def _server_values(doc: Any) -> dict[str, Any]:
try:
return spreadsheet.with_server_values(doc)