From abd0d4b126226035dcdbdc89b07d973e7c2ecdcf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 25 Sep 2026 12:00:54 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EC=A7=91=EA=B3=84=ED=91=9C=20=EB=AC=B6=EC=9D=8C=20=EC=97=B4?= =?UTF-8?q?=EC=9D=80=20=EA=B0=92=EB=A7=88=EB=8B=A4=20=ED=95=9C=EB=8D=B0=20?= =?UTF-8?q?=C2=B7=20=EB=B9=88=20=EC=84=A4=EA=B3=84=EA=B0=92=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=EB=8A=94=20=E3=80=8C=EB=86=92=EC=9D=B4=20=EB=AF=B8?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=E3=80=8D=20=EA=BC=B4=20=C2=B7=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=97=B4=20=EC=84=A4=EA=B3=84=EA=B0=92=20=EB=94=B1?= =?UTF-8?q?=EC=A7=80=20=EB=97=8C=20(=EB=B8=8C=EB=A0=88=EC=9D=B8=20?= =?UTF-8?q?=EC=B5=9C=EC=A2=85=20=ED=99=95=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관공 등 묶음 열 차례 = 관종·관경마다 관매설 · 커플링 짝 — 머리 합치기가 값마다 묶임 - 머리틀은 맨 자리표 + 값꼴(H={} · T={}cm · Φ{}) · 빈 값은 빈값 글 · 열 id 는 그대로(빈 값 -) - 포장 A · 수축줄눈 · 커플링 · 큰돌쌓기 면적 = 바인딩 대신 펼침틀(묶음 · 머리틀 · 식틀) - 식틀 자리 값이 비면 식을 빼고 설명에 적음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LuapLYqN1GGFD8Y1PStD5 --- M02_MasterTemplete/M02_Table_Fill.py | 135 ++++++++++++------ .../master_template/table/구조물집계표.json | 52 +++---- resources/tester/test_m02_table_fill.py | 29 +++- 3 files changed, 143 insertions(+), 73 deletions(-) diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index d5f32951..44dd9804 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -6,7 +6,8 @@ B06 횡단 설계 `design.pipe_length_m` 관 연장(부르는 쪽이 DB 에서 읽어 넘김) - 펼침 열 — 마스터 한 칸 → 설계에 쓰인 값마다 열(choices 차례) · 쓰인 값이 없으면 열을 뺌. - 열 id = `마스터 id|펼친 값|…`(머리 글자가 바뀌어도 id 는 그대로). + 열 id = `마스터 id|펼친 값|…`(머리 글자가 바뀌어도 id 는 그대로 · 빈 값은 `-`). + 한 묶음 열은 값마다 한데 모음 · 계산 열은 `펼침틀` 로 묶음을 따라 나뉨(바인딩 없음). - 줄 — 측점마다 한 줄(같은 측점 구조물은 한 줄) · 점 `NO.x+y` · 구간 `NO.a~NO.b` · 같은 측점에 관이 둘이면 줄을 나눔 · 줄 id 는 측점에서 지어 손 값이 다시 채워도 붙어 있음. - 손 열 값 · 전구간 줄 · 사용자가 더한 줄(`손`)은 작업본 것을 그대로 지킴. @@ -35,7 +36,10 @@ FACILITY_TYPES = { "ford_bridge": "ford_bridge", "revetment": "revetment", } -EMPTY_LABEL = "-" +EMPTY_LABEL = "-" # 열 id 안 빈 값(저장본과 맞춤) — 머리 글은 `빈값` +EMPTY_TEXT = "미입력" +#: 계산 열이 설계값 열 묶음을 따라 나뉠 때 쓰는 틀(바인딩 아님 — 표 부품이 설계값으로 안 봄). +FRAME_KEY = "펼침틀" ORPHAN_NOTE = "설계에서 측점 없어짐" #: 채운 표에 실어 보내는 펼침 전 열 — 저장 때 빠진 규칙 열을 되살리는 데만 씀. RULE_COLUMNS_KEY = "양식열" @@ -271,12 +275,31 @@ def _blank_vars(formula: str, variables: dict[str, Any]) -> list[str]: return [name for name in names if variables.get(name) in (None, "")] +def _frame(column: dict[str, Any]) -> dict[str, Any]: + """펼침 틀 — 설계값 열은 `바인딩` · 따라 나뉘는 계산 열은 `펼침틀`(설계값 딱지 없음).""" + return column.get("바인딩") or column.get(FRAME_KEY) or {} + + +def _group(column: dict[str, Any]) -> str: + return _frame(column).get("묶음") or column["id"] + + +def _head_part(frame: dict[str, Any], index: int, value: str) -> str: + """펼친 값 한 자리 머리 글 — `값꼴`(`H={}`) · 빈 값은 `빈값`(`높이 미입력`).""" + if value == EMPTY_LABEL: + empties = frame.get("빈값") or [] + return (empties[index] if index < len(empties) else "") or EMPTY_TEXT + shapes = frame.get("값꼴") or [] + shape = (shapes[index] if index < len(shapes) else "") or "{}" + return shape.replace("{}", str((frame.get("이름") or {}).get(value, value))) + + def _unexpand(columns: list[dict[str, Any]]) -> list[dict[str, Any]]: """채운 표가 작업본으로 저장됐으면 펼친 열을 마스터 칸(`원열`)으로 되돌림 — 두 번 안 펼침.""" out: list[dict[str, Any]] = [] seen: set[str] = set() for column in columns: - origin = (column.get("바인딩") or {}).get("원열") + origin = _frame(column).get("원열") if origin is None: out.append(column) elif origin["id"] not in seen: @@ -291,62 +314,82 @@ def _expand_columns( variables: dict[str, Any], choices: dict[str, list[str]], ) -> tuple[list[dict[str, Any]], dict[str, list[tuple[str, str, dict[str, Any]]]]]: - """펼침 열 → 값마다 열. 돌려주는 둘째 = `{master id: [(열 id, suffix, 펼친 열)]}`.""" + """펼침 열 → 값마다 열 · 묶음은 값마다 한데(관 Φ800 관매설 · 커플링 → Φ1000 …). + + 돌려주는 둘째 = `{master id: [(열 id, suffix, 펼친 열)]}`. + """ columns = _unexpand(columns) groups: dict[str, set[tuple[str, ...]]] = {} orders_of: dict[str, list[list[str]]] = {} + members: dict[str, list[dict[str, Any]]] = {} for column in columns: - binding = column.get("바인딩") or {} - if not column.get("펼침") or not binding: + if not column.get("펼침") or not _frame(column): continue - group = binding.get("묶음") or column["id"] - orders_of.setdefault(group, _position_orders(binding, choices)) - found = groups.setdefault(group, set()) + members.setdefault(_group(column), []).append(column) + binding = column.get("바인딩") + if not binding: + continue # 계산 열 — 설계값 열이 연 값을 따름 + orders_of.setdefault(_group(column), _position_orders(binding, choices)) + found = groups.setdefault(_group(column), set()) for source in _sources(binding): # 값이 빈 구조물도 열은 세움 — 놓였는데 수량이 빈칸인 것이 보여야 함 found.update(_expansion(source, item) for item in items if _matches(source, item)) - members: dict[str, set[str]] = {} - for column in columns: - binding = column.get("바인딩") or {} - if column.get("펼침") and binding: - members.setdefault(binding.get("묶음") or column["id"], set()).add(column["id"]) - out: list[dict[str, Any]] = [] made: dict[str, list[tuple[str, str, dict[str, Any]]]] = {} for column in columns: - binding = column.get("바인딩") or {} - if not column.get("펼침") or not binding: + if not column.get("펼침") or not _frame(column): out.append(copy.deepcopy(column)) continue - group = binding.get("묶음") or column["id"] - ordered = sorted(groups.get(group, set()), key=lambda v: _order_key(v, orders_of[group])) - names = binding.get("이름") or {} - for values in ordered: - shown = [names.get(value, value) for value in values] - suffix = "|".join(values) - new = copy.deepcopy(column) - new["id"] = f"{column['id']}|{suffix}" - new["펼침"] = False - new["머리"] = [ - None if part is None else part.format(*shown) - for part in (binding.get("머리틀") or column["머리"]) - ] - new["바인딩"] = {**binding, "펼친값": list(values), "원열": copy.deepcopy(column)} - formula = binding.get("식틀") - formula = formula.format(*values) if formula else column.get("식") - if formula: - formula = _rewrite(formula, members.get(group, set()), suffix) - if _blank_vars(formula, variables): - new.pop("식", None) - new["설명"] = f"{column.get('설명', '')} · 변수가 빈칸이라 계산 안 함".strip() - else: - new["식"] = formula - out.append(new) - made.setdefault(column["id"], []).append((new["id"], suffix, new)) + group = _group(column) + if members[group][0] is not column: + continue # 묶음 첫 열 자리에서 값마다 묶음 열을 한데 냄 + orders = orders_of.get(group, []) + sibling_ids = {member["id"] for member in members[group]} + for values in sorted(groups.get(group, set()), key=lambda v: _order_key(v, orders)): + for member in members[group]: + new = _expand_one(member, values, sibling_ids, variables) + out.append(new) + made.setdefault(member["id"], []).append((new["id"], "|".join(values), new)) return out, made +def _expand_one( + column: dict[str, Any], + values: tuple[str, ...], + siblings: set[str], + variables: dict[str, Any], +) -> dict[str, Any]: + frame = _frame(column) + suffix = "|".join(values) + shown = [_head_part(frame, index, value) for index, value in enumerate(values)] + new = copy.deepcopy(column) + new["id"] = f"{column['id']}|{suffix}" + new["펼침"] = False + new["머리"] = [ + None if part is None else part.format(*shown) + for part in (frame.get("머리틀") or column["머리"]) + ] + key = "바인딩" if column.get("바인딩") else FRAME_KEY + new[key] = {**frame, "펼친값": list(values), "원열": copy.deepcopy(column)} + template = frame.get("식틀") + used = [int(i) for i in re.findall(r"\{(\d+)\}", template or "")] + blank = "" + if any(values[i] == EMPTY_LABEL for i in used if i < len(values)): + formula, blank = None, "설계값이" + else: + formula = template.format(*values) if template else column.get("식") + if formula: + formula = _rewrite(formula, siblings, suffix) + blank = "변수가" if _blank_vars(formula, variables) else "" + if blank: + new.pop("식", None) + new["설명"] = f"{column.get('설명', '')} · {blank} 빈칸이라 계산 안 함".strip() + elif formula: + new["식"] = formula + return new + + def fill_document( document: dict[str, Any], items: list[dict[str, Any]], @@ -400,8 +443,8 @@ def fill_document( spacing = _number(item["options"].get("joint_spacing_m")) if item["type_id"] != "pavement_concrete" or spacing in (None, base_spacing): continue - for new_id, _, new in made.get("pv_jt", []): - if list(_expansion({"펼침": new["바인딩"]["펼침"]}, item)) == new["바인딩"]["펼친값"]: + for new_id, suffix, new in made.get("pv_jt", []): + if "|".join(_expansion({"펼침": ["thickness_cm"]}, item)) == suffix: text = new.get("식", "").replace("[$수축줄눈_간격_m]", _label("", spacing)) slots[slot].setdefault("식", {})[new_id] = text @@ -452,11 +495,11 @@ def fill_document( def _hand_ids(columns: list[dict[str, Any]]) -> set[str]: - """사람이 적는 열 — 바인딩 없는 열(손 열 · 비고 · 사용자가 더한 열) · 차례 · 측점 빼고.""" + """사람이 적는 열 — 설계값 · 계산 열 말고(손 열 · 비고 · 더한 열) · 차례 · 측점 빼고.""" return { column["id"] for column in columns - if not column.get("바인딩") and column["id"] not in ("no", "sta") + if not _frame(column) and not column.get("식") and column["id"] not in ("no", "sta") } diff --git a/resources/master_template/table/구조물집계표.json b/resources/master_template/table/구조물집계표.json index 0417e27f..6b91bff4 100644 --- a/resources/master_template/table/구조물집계표.json +++ b/resources/master_template/table/구조물집계표.json @@ -3,7 +3,7 @@ "종류": "표", "판": 1, "설명": "측점마다 구조물 수량 한 줄 · 머리 4층(종류 · 공법 · 규격 · 단위) · 설계값 열은 프로젝트 설계에서 채움(화면에서 못 고침 — 설계를 고침) · 손 열은 사용자가 적음 · 계산 열은 식. 종류 정본 = B05_Profile/B05_Profile_Structure_Types.json.", - "바인딩규칙": "종류 = type_id · 펼침 = 값마다 열을 나눌 칸 키(앞에 = 이면 고정 글) · 값 = 칸 키 | 개소(1건 1) | 길이(length_m, 없으면 끝-시작) | 관연장(B06 횡단 pipe_length_m) · 묶음 = 같은 펼침으로 함께 나뉘는 열 · 머리틀 = 펼친 머리({0}{1}… = 펼침 값 차례) · 이름 = 설계 값 → 머리 글 · 조건 = {칸: 값}(맞는 것만) · 더함 = 같은 열에 더하는 다른 출처 · 식틀 = 펼친 열의 식({0}… 펼침 값)", + "바인딩규칙": "종류 = type_id · 펼침 = 값마다 열을 나눌 칸 키(앞에 = 이면 고정 글) · 값 = 칸 키 | 개소(1건 1) | 길이(length_m, 없으면 끝-시작) | 관연장(B06 횡단 pipe_length_m) · 묶음 = 같은 펼침으로 함께 나뉘는 열 · 머리틀 = 펼친 머리({0}{1}… = 펼침 값 차례) · 이름 = 설계 값 → 머리 글 · 조건 = {칸: 값}(맞는 것만) · 더함 = 같은 열에 더하는 다른 출처 · 식틀 = 펼친 열의 식({0}… 펼침 값) · 값꼴 = 자리마다 머리 글 꼴({} = 값) · 빈값 = 설계값이 빈 자리 머리 글 · 펼침틀 = 계산 열이 같은 묶음으로 따라 나뉨(묶음 · 머리틀 · 값꼴 · 빈값 · 식틀 — 설계값을 받지 않아 바인딩 없음)", "층": ["종류", "공법", "규격"], "변수": { "파형강관_1본_m": 8, @@ -23,7 +23,7 @@ "일위대가": "UA000004", "바인딩": { "종류": "revetment", "펼침": ["form", "height_m"], "값": "length_m", "묶음": "rv", - "머리틀": ["돌기슭막이", "{0}", "H={1}"], + "머리틀": ["돌기슭막이", "{0}", "{1}"], "값꼴": ["{}", "H={}"], "빈값": ["형태 미입력", "높이 미입력"], "이름": { "돌쌓기(찰)": "찰쌓기", "돌쌓기(메)": "메쌓기" }, "더함": [ { "종류": "pipe", "펼침": ["inlet_revet_form", "inlet_revet_height_m"], "값": "inlet_revet_length_m", "조건": { "inlet_type": "기슭막이" } }, @@ -35,18 +35,18 @@ { "id": "pv_b", "머리": ["콘크리트포장", "T=(두께마다)", "B"], "단위": "m", "꼴": "수", "펼침": true, "설명": "설계값 — 포장 폭", - "바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "width_m", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "B"] } + "바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "width_m", "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "B"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] } }, { "id": "pv_l", "머리": ["콘크리트포장", "T=(두께마다)", "L"], "단위": "m", "꼴": "수", "펼침": true, "설명": "설계값 — 포장 길이", "일위대가": "UA000007", - "바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "길이", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "L"] } + "바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "길이", "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "L"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] } }, { "id": "pv_w", "머리": ["콘크리트포장", "T=(두께마다)", "확폭"], "단위": "㎡", "꼴": "수", "펼침": true, "설명": "설계값 — 확폭 면적", - "바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "widening_area_m2", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "확폭"] } + "바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "widening_area_m2", "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "확폭"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] } }, { "id": "pv_a", "머리": ["콘크리트포장", "T=(두께마다)", "A"], "단위": "㎡", "꼴": "수", "펼침": true, @@ -54,14 +54,14 @@ "끝수": { "자리": 2, "방법": "반올림" }, "설명": "계산 — B × L + 확폭", "일위대가": "UA000006", - "바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "식", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "A"] } + "펼침틀": { "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "A"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] } }, { "id": "pv_jt", "머리": ["콘크리트포장", "T=(두께마다)", "수축줄눈"], "단위": "m", "꼴": "수", "펼침": true, "식": "IF([pv_l]>0,INT([pv_l]/[$수축줄눈_간격_m])*[pv_b],\"\")", "끝수": { "자리": 2, "방법": "반올림" }, "설명": "계산 — 내림(L ÷ 줄눈 간격) × B · 줄눈 간격 = 설계값(기본 6m · 다르면 그 줄 식에 박음)", - "바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "식", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "수축줄눈"] } + "펼침틀": { "묶음": "pv", "머리틀": ["콘크리트포장", "{0}", "수축줄눈"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] } }, { @@ -69,14 +69,14 @@ "설명": "설계값 — 횡단 관 연장(B06) · 같은 측점에 관이 둘이면 줄을 나눔", "일위대가": "UA000036", "일위대가후보": ["UA000036", "UA000027"], - "바인딩": { "종류": "pipe", "펼침": ["pipe_kind", "pipe_diameter_mm"], "값": "관연장", "묶음": "pp", "머리틀": ["관공", "{0} Φ{1}", "관매설"] } + "바인딩": { "종류": "pipe", "펼침": ["pipe_kind", "pipe_diameter_mm"], "값": "관연장", "묶음": "pp", "머리틀": ["관공", "{0} {1}", "관매설"], "값꼴": ["{}", "Φ{}"], "빈값": ["관종 미입력", "관경 미입력"] } }, { "id": "pp_cp", "머리": ["관공", "(관종·관경마다)", "커플링밴드"], "단위": "개소", "꼴": "수", "펼침": true, "식": "IF([pp_len]>0,ROUNDUP([pp_len]/[$파형강관_1본_m],0)-1,\"\")", "설명": "계산 — 올림(L ÷ 1본 길이) − 1 · 관마다(줄마다) 계산 뒤 합 · 1본 길이 = 변수(관종마다 · 빈칸이면 계산 안 함)", - "바인딩": { - "종류": "pipe", "펼침": ["pipe_kind", "pipe_diameter_mm"], "값": "식", "묶음": "pp", "머리틀": ["관공", "{0} Φ{1}", "커플링밴드"], + "펼침틀": { + "묶음": "pp", "머리틀": ["관공", "{0} {1}", "커플링밴드"], "값꼴": ["{}", "Φ{}"], "빈값": ["관종 미입력", "관경 미입력"], "식틀": "IF([pp_len]>0,ROUNDUP([pp_len]/[${0}_1본_m],0)-1,\"\")" } }, @@ -87,7 +87,7 @@ "일위대가": "UA000008", "일위대가후보": ["UA000008", "UA000025", "UA000029"], "바인딩": { - "종류": "pipe", "펼침": ["wing_wall_type"], "값": "개소", "묶음": "pg", "머리틀": ["관보호공", "{0}", "유입"], + "종류": "pipe", "펼침": ["wing_wall_type"], "값": "개소", "묶음": "pg", "머리틀": ["관보호공", "{0}", "유입"], "빈값": ["형식 미입력"], "이름": { "A-TYPE": "날개벽 A형", "C-TYPE": "날개벽 C형", "A-TYPE+집수정": "날개벽 A형+집수정" }, "더함": [ { "종류": "pipe", "펼침": ["inlet_basin_form"], "값": "개소", "조건": { "inlet_type": "집수정" } } ] } @@ -97,14 +97,14 @@ "id": "bx_len", "머리": ["BOX암거", "(폭×높이마다)", "연장"], "단위": "m", "꼴": "수", "펼침": true, "설명": "설계값 — BOX 연장(계류 방향)", "일위대가": "UA000014", - "바인딩": { "종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "length_m", "묶음": "bx", "머리틀": ["BOX암거", "{0}×{1}", "연장"] } + "바인딩": { "종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "length_m", "묶음": "bx", "머리틀": ["BOX암거", "{0}×{1}", "연장"], "빈값": ["폭 미입력", "높이 미입력"] } }, { "id": "bx_wing", "머리": ["BOX암거", "(폭×높이마다)", "날개벽"], "단위": "개소", "꼴": "수", "펼침": true, "설명": "설계값 — 날개벽 유입·유출 있음 수", "일위대가": "UA000025", "바인딩": { - "종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "개소", "묶음": "bx", "머리틀": ["BOX암거", "{0}×{1}", "날개벽"], + "종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "개소", "묶음": "bx", "머리틀": ["BOX암거", "{0}×{1}", "날개벽"], "빈값": ["폭 미입력", "높이 미입력"], "조건": { "wing_in": "있음" }, "더함": [ { "종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "개소", "조건": { "wing_out": "있음" } } ] } @@ -113,32 +113,32 @@ { "id": "fb", "머리": ["세월교", "(관경 × 련수마다)", null], "단위": "개소", "꼴": "수", "펼침": true, "설명": "설계값 — 세월교 개소", - "바인딩": { "종류": "ford_bridge", "펼침": ["pipe_diameter_mm", "pipe_count"], "값": "개소", "묶음": "fb", "머리틀": ["세월교", "Φ{0}×{1}련", null] } + "바인딩": { "종류": "ford_bridge", "펼침": ["pipe_diameter_mm", "pipe_count"], "값": "개소", "묶음": "fb", "머리틀": ["세월교", "{0}×{1}", null], "값꼴": ["Φ{}", "{}련"], "빈값": ["관경 미입력", "련수 미입력"] } }, { "id": "fp_w", "머리": ["물넘이포장", "T=(두께마다)", "폭"], "단위": "m", "꼴": "수", "펼침": true, "설명": "설계값 — 월류 폭", - "바인딩": { "종류": "ford_pavement", "펼침": ["thickness_cm"], "값": "ford_width_m", "묶음": "fp", "머리틀": ["물넘이포장", "T={0}cm", "폭"] } + "바인딩": { "종류": "ford_pavement", "펼침": ["thickness_cm"], "값": "ford_width_m", "묶음": "fp", "머리틀": ["물넘이포장", "{0}", "폭"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] } }, { "id": "fp_l", "머리": ["물넘이포장", "T=(두께마다)", "길이"], "단위": "m", "꼴": "수", "펼침": true, "설명": "설계값 — 포장 길이(노폭 방향)", - "바인딩": { "종류": "ford_pavement", "펼침": ["thickness_cm"], "값": "length_m", "묶음": "fp", "머리틀": ["물넘이포장", "T={0}cm", "길이"] } + "바인딩": { "종류": "ford_pavement", "펼침": ["thickness_cm"], "값": "length_m", "묶음": "fp", "머리틀": ["물넘이포장", "{0}", "길이"], "값꼴": ["T={}cm"], "빈값": ["두께 미입력"] } }, { "id": "od_len", "머리": ["횡단개거", "(규격마다)", "길이"], "단위": "m", "꼴": "수", "펼침": true, "설명": "설계값 — 개거 연장", "일위대가": "UA000026", - "바인딩": { "종류": "open_ditch", "펼침": ["ditch_spec"], "값": "length_m", "묶음": "od", "머리틀": ["횡단개거", "{0}", "길이"] } + "바인딩": { "종류": "open_ditch", "펼침": ["ditch_spec"], "값": "length_m", "묶음": "od", "머리틀": ["횡단개거", "{0}", "길이"], "빈값": ["규격 미입력"] } }, { "id": "od_cnt", "머리": ["횡단개거", "(규격마다)", "개소"], "단위": "개소", "꼴": "수", "펼침": true, "설명": "설계값 — 개거 · 노출형 횡단수로 개소", "일위대가": "UA000003", "바인딩": { - "종류": "open_ditch", "펼침": ["ditch_spec"], "값": "개소", "묶음": "od", "머리틀": ["횡단개거", "{0}", "개소"], + "종류": "open_ditch", "펼침": ["ditch_spec"], "값": "개소", "묶음": "od", "머리틀": ["횡단개거", "{0}", "개소"], "빈값": ["규격 미입력"], "더함": [ { "종류": "cross_drain_exposed", "펼침": ["=노출형"], "값": "개소" } ] } }, @@ -146,13 +146,13 @@ { "id": "rw", "머리": ["옹벽", "(형식마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true, "설명": "설계값 — 옹벽 길이", - "바인딩": { "종류": "retaining_wall", "펼침": ["form", "height_m"], "값": "길이", "묶음": "rw", "머리틀": ["옹벽", "{0}", "H={1}"] } + "바인딩": { "종류": "retaining_wall", "펼침": ["form", "height_m"], "값": "길이", "묶음": "rw", "머리틀": ["옹벽", "{0}", "{1}"], "값꼴": ["{}", "H={}"], "빈값": ["형식 미입력", "높이 미입력"] } }, { "id": "ms", "머리": ["돌쌓기", "(찰·메마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true, "설명": "설계값 — 돌쌓기 길이", "바인딩": { - "종류": "masonry_wet", "펼침": ["=찰쌓기", "height_m"], "값": "길이", "묶음": "ms", "머리틀": ["돌쌓기", "{0}", "H={1}"], + "종류": "masonry_wet", "펼침": ["=찰쌓기", "height_m"], "값": "길이", "묶음": "ms", "머리틀": ["돌쌓기", "{0}", "{1}"], "값꼴": ["{}", "H={}"], "빈값": ["", "높이 미입력"], "더함": [ { "종류": "masonry_dry", "펼침": ["=메쌓기", "height_m"], "값": "길이" } ] } }, @@ -160,19 +160,19 @@ "id": "sg", "머리": ["흙막이", "(형태마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true, "설명": "설계값 — 흙막이 길이 · 일위대가는 큰돌만", "일위대가": "UA000032", - "바인딩": { "종류": "soil_guard", "펼침": ["form", "height_m"], "값": "길이", "묶음": "sg", "머리틀": ["흙막이", "{0}", "H={1}"] } + "바인딩": { "종류": "soil_guard", "펼침": ["form", "height_m"], "값": "길이", "묶음": "sg", "머리틀": ["흙막이", "{0}", "{1}"], "값꼴": ["{}", "H={}"], "빈값": ["형식 미입력", "높이 미입력"] } }, { "id": "bm_len", "머리": ["큰돌쌓기", "(찰·메 · 돌 크기 · 높이마다)", "길이"], "단위": "m", "꼴": "수", "펼침": true, "설명": "설계값 — 큰돌쌓기 길이", - "바인딩": { "종류": "boulder_masonry", "펼침": ["bond", "stone_cm", "height_m"], "값": "길이", "묶음": "bm", "머리틀": ["큰돌쌓기", "{0} {1} H={2}", "길이"] } + "바인딩": { "종류": "boulder_masonry", "펼침": ["bond", "stone_cm", "height_m"], "값": "길이", "묶음": "bm", "머리틀": ["큰돌쌓기", "{0} {1} {2}", "길이"], "값꼴": ["{}", "{}", "H={}"], "빈값": ["쌓기 방식 미입력", "돌규격 미입력", "높이 미입력"] } }, { "id": "bm_area", "머리": ["큰돌쌓기", "(찰·메 · 돌 크기 · 높이마다)", "면적"], "단위": "㎡", "꼴": "수", "펼침": true, "설명": "계산 — L × H (H = 펼친 높이)", "일위대가": "UA000031", - "바인딩": { - "종류": "boulder_masonry", "펼침": ["bond", "stone_cm", "height_m"], "값": "식", "묶음": "bm", "머리틀": ["큰돌쌓기", "{0} {1} H={2}", "면적"], + "펼침틀": { + "묶음": "bm", "머리틀": ["큰돌쌓기", "{0} {1} {2}", "면적"], "값꼴": ["{}", "{}", "H={}"], "빈값": ["쌓기 방식 미입력", "돌규격 미입력", "높이 미입력"], "식틀": "IF([bm_len]>0,[bm_len]*{2},\"\")" } }, @@ -180,7 +180,7 @@ { "id": "ec", "머리": ["골막이", "(형태마다)", null], "단위": "개소", "꼴": "수", "펼침": true, "설명": "설계값 — 골막이 개소", - "바인딩": { "종류": "erosion_check", "펼침": ["form"], "값": "개소", "묶음": "ec", "머리틀": ["골막이", "{0}", null] } + "바인딩": { "종류": "erosion_check", "펼침": ["form"], "값": "개소", "묶음": "ec", "머리틀": ["골막이", "{0}", null], "빈값": ["형식 미입력"] } }, { "id": "be", "머리": ["소단", null, null], "단위": "m", "꼴": "수", @@ -200,7 +200,7 @@ { "id": "etc", "머리": ["기타", "(이름마다)", null], "단위": "개소", "꼴": "수", "펼침": true, "설명": "설계값 — 기타 구조물 개소", - "바인딩": { "종류": "etc", "펼침": ["name"], "값": "개소", "묶음": "etc", "머리틀": ["기타", "{0}", null] } + "바인딩": { "종류": "etc", "펼침": ["name"], "값": "개소", "묶음": "etc", "머리틀": ["기타", "{0}", null], "빈값": ["이름 미입력"] } }, { "id": "h_intake", "머리": ["취수정", null, null], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000005" }, diff --git a/resources/tester/test_m02_table_fill.py b/resources/tester/test_m02_table_fill.py index bffc7425..a3086885 100644 --- a/resources/tester/test_m02_table_fill.py +++ b/resources/tester/test_m02_table_fill.py @@ -92,10 +92,37 @@ def test_펼침_열은_설계에_쓰인_값만_차례대로(project: Path) -> No assert "rv" not in ids and "ps" in ids # 펼침 마스터 칸은 빠지고 고정 열은 남음 # 관 유입·유출 기슭막이는 레지스트리 기본값(찰 · 메 · 높이 없음)으로 열이 섬 · id 는 설계 값 assert "rv|돌쌓기(찰)|-" in ids and "rv|돌쌓기(메)|-" in ids - assert _col(doc, "rv|돌쌓기(찰)|-")["머리"] == ["돌기슭막이", "찰쌓기", "H=-"] + assert _col(doc, "rv|돌쌓기(찰)|-")["머리"] == ["돌기슭막이", "찰쌓기", "높이 미입력"] + assert _col(doc, "fb|1000|-")["머리"] == ["세월교", "Φ1000×련수 미입력", None] assert not any(i.startswith("pg_in|") for i in ids) # 날개벽·집수정 형식 없음 → 관보호공 없음 +def test_묶음_열은_값마다_한데(project: Path) -> None: + ids = [column["id"] for column in _filled(project)["열"]] + assert [i for i in ids if i.startswith("pp_")] == [ + "pp_len|흄관|1000", + "pp_cp|흄관|1000", + "pp_len|파형강관|1000", + "pp_cp|파형강관|1000", + ] + assert [i for i in ids if i.startswith("pv_")] == [ + f"pv_{k}|20" for k in ("b", "l", "w", "a", "jt") + ] + + +def test_계산_열은_설계값_딱지_없음(project: Path) -> None: + master = _master() + assert not [c["id"] for c in master["열"] if c.get("식") and c.get("바인딩")] + doc = _filled(project) + for col_id in ("pv_a|20", "pv_jt|20", "pp_cp|파형강관|1000", "bm_area|메쌓기|40~60|2.5"): + column = _col(doc, col_id) + assert "바인딩" not in column and column["펼침틀"]["원열"]["id"] == col_id.split("|")[0] + assert _col(doc, "pp_cp|파형강관|1000")["머리"] == ["관공", "파형강관 Φ1000", "커플링밴드"] + # 다시 저장 · 채워도 같은 열(계산 열도 원열로 되돌림) + again = fill.fill_table(project, fill.strip_design(doc), {85.0: 14.0}) + assert [c["id"] for c in again["열"]] == [c["id"] for c in doc["열"]] + + def test_계산_열_식은_같은_묶음_열로_바뀌고_빈_변수면_안_셈(project: Path) -> None: doc = _filled(project) assert _col(doc, "pv_a|20")["식"] == 'IF([pv_l|20]>0,[pv_b|20]*[pv_l|20]+[pv_w|20],"")' From c0eb280c32e92daf4fc9916565c24445471c6c4e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 25 Sep 2026 12:01:51 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(M02):=20=EB=8F=84=EB=A9=B4=20=EC=96=91?= =?UTF-8?q?=EC=8B=9D=20CAD=20=EB=A8=B8=EB=A6=AC=20=EA=B8=80=EC=9D=84=20?= =?UTF-8?q?=E3=80=8C=EB=8F=84=EB=A9=B4=20=EC=96=91=EC=8B=9D=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=E3=80=8D=EC=9C=BC=EB=A1=9C=20=C2=B7=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=8B=A8=EC=B6=94=EB=8A=94=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=A8=B8=EB=A6=AC=20=ED=95=9C=20=EA=B3=B3=20=C2=B7?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=EB=B0=B1=EC=97=85=20=EC=B9=B8=EC=97=90=20?= =?UTF-8?q?=EC=B8=B5(=EA=B3=BC=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8)?= =?UTF-8?q?=EC=9D=84=20=EB=B6=99=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6 --- .../src/components/QuickAccessBar.tsx | 16 ++++- .../src/integration/aislo-drawing-bridge.ts | 4 ++ B07_DesignDetail/openwebcad/src/state.ts | 7 ++ .../M02_MasterTemplete_Drawing.ts | 64 ++++++++++++------- .../M02_MasterTemplete_UI_Main.ts | 2 + ui_template/cad_host/cad_host.ts | 2 + 6 files changed, 68 insertions(+), 27 deletions(-) diff --git a/B07_DesignDetail/openwebcad/src/components/QuickAccessBar.tsx b/B07_DesignDetail/openwebcad/src/components/QuickAccessBar.tsx index 3050cc48..46476910 100644 --- a/B07_DesignDetail/openwebcad/src/components/QuickAccessBar.tsx +++ b/B07_DesignDetail/openwebcad/src/components/QuickAccessBar.tsx @@ -1,14 +1,23 @@ /** 제목표시줄 + 빠른 실행 도구막대 (AutoCAD 상단 막대) */ -import type { FC } from 'react'; +import { type FC, useEffect, useState } from 'react'; +import { HtmlEvent } from '../App.types'; import { getCommandById } from '../commands/registry'; import { runCommand } from '../commands/run-command'; import { QUICK_ACCESS_COMMANDS } from '../ribbon/ribbon.config'; +import { getHostTitle } from '../state'; -export const QuickAccessBar: FC = () => ( +export const QuickAccessBar: FC = () => { + const [hostTitle, setTitle] = useState(getHostTitle()); + useEffect(() => { + const refresh = () => setTitle(getHostTitle()); + window.addEventListener(HtmlEvent.UPDATE_STATE, refresh); + return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh); + }, []); + return (
Aislo CAD - B07 상세 설계 + {hostTitle}
{QUICK_ACCESS_COMMANDS.map((id) => { @@ -32,3 +41,4 @@ export const QuickAccessBar: FC = () => (
); +}; diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index 3ffebc53..755abd0f 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -21,6 +21,7 @@ import { setEntities, setFrameEditMode, setHostReadOnly, + setHostTitle, setLayers, } from '../state.ts'; import { toast } from 'react-toastify'; @@ -50,6 +51,8 @@ interface DrawingLoadMessage { recoveryScope?: string; /** 보기 전용으로 싣는가 — 확정본처럼 그리기·수정을 막는다. */ readOnly?: boolean; + /** 제목표시줄 부제 — 없으면 「B07 상세 설계」. */ + hostTitle?: string; } interface DrawingSaveRequestMessage { @@ -193,6 +196,7 @@ export function registerAisloDrawingBridge() { setDesignMeta(event.data.meta ?? null); setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {}); setHostReadOnly(event.data.readOnly === true); + setHostTitle(event.data.hostTitle ?? 'B07 상세 설계'); if (event.data.frameEdit) applyFramePreview(); // 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도** // 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측: diff --git a/B07_DesignDetail/openwebcad/src/state.ts b/B07_DesignDetail/openwebcad/src/state.ts index aa51cd94..785555dd 100644 --- a/B07_DesignDetail/openwebcad/src/state.ts +++ b/B07_DesignDetail/openwebcad/src/state.ts @@ -192,6 +192,7 @@ let frameEditMode = false; let frameFields: Record = {}; /** 부모가 보기 전용으로 실었는가 — M02 에서 고칠 권한이 없는 양식을 볼 때. */ let hostReadOnly = false; +let hostTitle = 'B07 상세 설계'; /** * 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 — @@ -496,6 +497,12 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => { }; /** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */ /** 부모가 실은 도면을 보기 전용으로 둔다 — 확정본과 같은 막힘(도면을 실을 때마다 새로 정함). */ +/** 제목표시줄 부제 — 부모가 도면을 실을 때 정함(없으면 B07 기본 글). */ +export const getHostTitle = (): string => hostTitle; +export const setHostTitle = (title: string) => { + hostTitle = title; + notifyWindow(HtmlEvent.UPDATE_STATE); +}; export const setHostReadOnly = (readOnly: boolean) => { hostReadOnly = readOnly; notifyWindow(HtmlEvent.UPDATE_STATE); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts index 5c7d42ea..b3bbbecb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts @@ -24,12 +24,42 @@ export interface DrawingTemplateDoc extends CadHostDrawing { } export interface DrawingTemplateOptions { - /** [저장] 을 누르면 편집본을 넘긴다. 없으면 [저장] 단추를 두지 않는다. */ + /** 쓰지 않음 — 저장은 페이지 머리 [저장] 한 곳(`getDoc()` 으로 편집본을 가져감 · 판 · 409 흐름). */ onSave?: (doc: DrawingTemplateDoc) => void | Promise; /** 보기 전용 — CAD 그리기·수정 · 작도 영역 · 불러오기가 막힌다. */ readOnly?: boolean; /** 양식 이름 — CAD 자동백업 칸을 양식마다 나눈다(B07 도면 백업과도 안 겹침). */ name?: string; + /** 양식 층 — 같은 이름도 층마다 백업 칸이 갈린다(`m02:층:이름`). */ + layer?: string; + /** 프로젝트 층이면 프로젝트 id — 칸이 `m02:project:프로젝트id:이름` 이 된다. */ + projectId?: string | null; +} + +const RECOVERY_PREFIX = "OPEN_WEB_CAD__RECOVERY__"; + +/** 자동백업 칸 이름 — 층(과 프로젝트)을 붙여 같은 이름의 양식끼리 안 겹치게 한다. */ +function recoveryScopeOf(options: DrawingTemplateOptions): string { + const name = options.name ?? "drawing"; + if (!options.layer) return `m02:${name}`; + const project = options.layer === "project" ? `:${options.projectId ?? ""}` : ""; + return `m02:${options.layer}${project}:${name}`; +} + +/** 층 없는 옛 칸(`m02:이름`)을 새 칸으로 한 번 옮기고 지운다 — 새 칸에 이미 있으면 그대로 두고 지움. */ +function migrateOldRecovery(options: DrawingTemplateOptions, scope: string): void { + const old = `${RECOVERY_PREFIX}m02:${options.name ?? "drawing"}`; + if (!options.layer || old === `${RECOVERY_PREFIX}${scope}`) return; + try { + const value = localStorage.getItem(old); + if (value === null) return; + if (localStorage.getItem(`${RECOVERY_PREFIX}${scope}`) === null) { + localStorage.setItem(`${RECOVERY_PREFIX}${scope}`, value); + } + localStorage.removeItem(old); + } catch { + // 저장소를 못 써도 편집은 됨 + } } export interface DrawingTemplateHandle { @@ -79,7 +109,8 @@ export function mountDrawingTemplate( options: DrawingTemplateOptions = {}, ): DrawingTemplateHandle { const readOnly = options.readOnly === true; - const recoveryScope = `m02:${options.name ?? "drawing"}`; + const recoveryScope = recoveryScopeOf(options); + migrateOldRecovery(options, recoveryScope); let base: DrawingTemplateDoc = doc; const root = document.createElement("div"); @@ -139,7 +170,13 @@ export function mountDrawingTemplate( const cad = createCadHost({ title: "도면 양식" }); const load = (drawing: DrawingTemplateDoc): void => { cad.beginLoading(); - cad.load(drawing, null, !readOnly, {}, { recoveryScope, readOnly }); + cad.load( + drawing, + null, + !readOnly, + {}, + { recoveryScope, readOnly, hostTitle: "도면 양식 편집" }, + ); }; const actions = document.createElement("div"); @@ -184,27 +221,6 @@ export function mountDrawingTemplate( return base; }; - if (options.onSave && !readOnly) { - const onSave = options.onSave; - const saveButton = createButton({ - label: "저장", - variant: "filled", - onClick: () => { - saveButton.disabled = true; - getDoc() - .then((next) => onSave(next)) - .catch((error) => - showToast( - error instanceof Error ? error.message : "양식을 저장하지 못했습니다.", - "error", - ), - ) - .finally(() => (saveButton.disabled = false)); - }, - }); - actions.append(saveButton); - } - toolbar.append(area, fields, actions); root.append(toolbar, cad.element); container.append(root); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 75b39afe..5418cd04 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -116,6 +116,8 @@ export function createMain(isAdmin: boolean): MainHandle { onSave: () => void doSave(), readOnly, name: at.name, + layer: at.layer, + projectId: at.projectId, }); return; } diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts index 9651c3e8..34e4a674 100644 --- a/ui_template/cad_host/cad_host.ts +++ b/ui_template/cad_host/cad_host.ts @@ -56,6 +56,8 @@ export interface CadLoadExtra { recoveryScope?: string; /** 보기 전용 — 확정본처럼 그리기·수정이 막힌다. */ readOnly?: boolean; + /** CAD 제목표시줄 부제 — 없으면 「B07 상세 설계」. */ + hostTitle?: string; } export interface CadHost { From 6cc98d68f1efd244cb8102c5d97aa3d7a0593774 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 25 Sep 2026 12:02:13 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(sheet):=20=EB=A7=88=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=20=EB=94=B1=EC=A7=80=EB=8A=94=20=EC=8B=9D=20=EB=A8=BC=EC=A0=80?= =?UTF-8?q?=20=C2=B7=20=EC=8B=9D=20=EC=A4=84=EC=9D=80=20=EC=97=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=C2=B7=20=EB=A8=B8=EB=A6=AC=20=EA=B8=80=20?= =?UTF-8?q?=EC=95=88=20=EC=9E=98=EB=A6=AC=EB=8A=94=20=EC=B5=9C=EC=86=8C=20?= =?UTF-8?q?=ED=8F=AD=20=C2=B7=20=EC=AA=BD=20=EB=82=98=EB=88=94=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20(M02=20=EB=B8=8C=EB=A0=88=EC=9D=B8=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 식 있는 열 = 계산 · 바인딩만 = 설계값(펼침이면 값마다 열) · 손 = 손 입력 - 식 줄은 [열id] 대신 머리 글 이름 · 같은 종류는 짧게 · 풍선은 긴 이름 + id 식 · 변수도 이름 - 열 폭 최소 = 머리 글 · 단위 폭(캔버스로 잼) · 들어갈 것 · 식 줄은 세 줄로 자르고 풍선에 전문 · 머리 칸도 풍선 - 쪽 나눔을 pagePlan 으로 떼어 시험 test_sheet_pages.py(줄 120 → 쪽 3 · 합계 마지막 쪽만) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1 --- resources/tester/test_sheet_pages.py | 81 ++++++++++++++ ui_template/sheet/ui_template_sheet.css | 8 ++ ui_template/sheet/ui_template_sheet.ts | 4 +- ui_template/sheet/ui_template_sheet_labels.ts | 103 ++++++++++++++++++ ui_template/sheet/ui_template_sheet_ops.ts | 22 ++++ ui_template/sheet/ui_template_sheet_render.ts | 78 +++++++++---- 6 files changed, 272 insertions(+), 24 deletions(-) create mode 100644 resources/tester/test_sheet_pages.py create mode 100644 ui_template/sheet/ui_template_sheet_labels.ts diff --git a/resources/tester/test_sheet_pages.py b/resources/tester/test_sheet_pages.py new file mode 100644 index 00000000..afeedecb --- /dev/null +++ b/resources/tester/test_sheet_pages.py @@ -0,0 +1,81 @@ +"""표 쪽 나눔 — `ui_template_sheet_ops.ts` `pagePlan` 을 Node 로 바로 돌려 봄. + +격자(`_render`)가 쪽을 이 계획대로 그림. + +줄 120 문서 → project 쪽 3(50 · 50 · 20) · 쪽마다 머리(격자가 쪽마다 thead) · 합계는 마지막 쪽만 · +「전구간」 줄은 맨 위 · master 는 한 쪽. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +OPS = ROOT / "ui_template" / "sheet" / "ui_template_sheet_ops.ts" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + +SCRIPT = """ +const { pagePlan } = await import(process.argv[1]); +const doc = JSON.parse(process.argv[2]); +const out = {}; +for (const mode of ["project", "master"]) { + out[mode] = pagePlan(doc, mode).map((p) => ({ + ids: p.rows.map((r) => r.id), first: p.first, totals: p.totals, + })); +} +console.log(JSON.stringify(out)); +""" + + +def _plan(doc: dict) -> dict: + result = subprocess.run( + [ + "node", + "--experimental-strip-types", + "--no-warnings", + "--input-type=module", + "-e", + SCRIPT, + OPS.as_uri(), + json.dumps(doc, ensure_ascii=False), + ], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _doc(n: int, **head) -> dict: + rows = [{"id": f"p{i}", "값": {}} for i in range(1, n)] + rows.insert(7, {"id": "all", "값": {}, "고정": "전구간"}) # 문서 가운데 있어도 맨 위로 + return {"양식": "시험", "종류": "표", "판": 1, "열": [], "줄": rows, **head} + + +def test_120_rows_make_three_pages_totals_last(): + plan = _plan(_doc(120))["project"] + assert [len(p["ids"]) for p in plan] == [50, 50, 20] + assert [p["first"] for p in plan] == [1, 51, 101] + assert [p["totals"] for p in plan] == [False, False, True] + assert plan[0]["ids"][0] == "all" + assert sum((p["ids"] for p in plan), []).count("all") == 1 + + +def test_page_rows_from_doc_and_master_single_page(): + got = _plan(_doc(55, 쪽줄=20)) + assert [len(p["ids"]) for p in got["project"]] == [20, 20, 15] + assert [len(p["ids"]) for p in got["master"]] == [55] + assert got["master"][0]["totals"] is True + + +def test_empty_doc_still_one_page_with_totals(): + plan = _plan({"양식": "시험", "종류": "표", "판": 1, "열": [], "줄": []})["project"] + assert plan == [{"ids": [], "first": 1, "totals": True}] diff --git a/ui_template/sheet/ui_template_sheet.css b/ui_template/sheet/ui_template_sheet.css index 8255b423..35ee2794 100644 --- a/ui_template/sheet/ui_template_sheet.css +++ b/ui_template/sheet/ui_template_sheet.css @@ -144,6 +144,14 @@ font-weight: 600; } +.ui-sheet__clamp { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; +} + .ui-sheet__chip { display: inline-block; margin-right: 4px; diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index 6545381b..4b013afb 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -23,7 +23,6 @@ import { } from "./ui_template_sheet_ops"; import { recalcSheet } from "./ui_template_sheet_recalc"; import { - columnWidth, KEY_UNIT, keyEditable, navRows, @@ -280,7 +279,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio event.preventDefault(); handle.setPointerCapture(event.pointerId); const total = parseFloat(scroll.querySelector("table")?.style.width ?? "0"); - drag = { id: col.id, x: event.clientX, width: columnWidth(state.doc, col), total }; + const now = scroll.querySelector(`col[data-col="${CSS.escape(col.id)}"]`); + drag = { id: col.id, x: event.clientX, width: parseFloat(now?.style.width ?? "72"), total }; }; const onPointerMove = (event: PointerEvent): void => { if (!drag) return; diff --git a/ui_template/sheet/ui_template_sheet_labels.ts b/ui_template/sheet/ui_template_sheet_labels.ts new file mode 100644 index 00000000..e86c37dc --- /dev/null +++ b/ui_template/sheet/ui_template_sheet_labels.ts @@ -0,0 +1,103 @@ +/* ============================================================================= + * ui_template_sheet_labels.ts + * 사람이 읽는 글 — 식의 열 id 를 열 이름(머리 글)으로 · 머리 글이 안 잘리는 최소 열 폭. + * + * 열 이름 = 맨 위 글(종류) + 맨 아래 글 · 다른 열과 겹치면 가운데 층을 더 붙임(자리 글 「(…마다)」 는 뺌). + * 보이기만 바꿈 — 문서의 식은 그대로 id 식(고칠 때도 id 식). + * ========================================================================== */ + +import { layoutHead } from "./ui_template_sheet_header"; +import type { SheetColumn } from "./ui_template_sheet_types"; + +const PLACEHOLDER = /[((][^))]*마다[))]/; +const NAME_PAD = 16; +const MAX_MIN_WIDTH = 220; + +export interface ColumnName { + /** 맨 위 글(종류) + 아래 글 — 표 전체에서 안 겹침. */ + full: string; + /** 같은 종류 안에서 부를 이름 — 겹치면 `full`. */ + short: string; + top: string; +} + +/** 열 id → 열 이름. */ +export function columnNames(cols: SheetColumn[]): Map { + const paths = cols.map((c) => { + const path = c.머리.filter((label): label is string => !!label && !PLACEHOLDER.test(label)); + return path.length ? path : [c.id]; + }); + /** 맨 위 + 아래서 `take` 층 — 모자라면 통째. */ + const nameOf = (path: string[], take: number): string => + (take + 1 >= path.length ? path : [path[0], ...path.slice(-take)]).join(" "); + const names = new Map(); + cols.forEach((col, j) => { + const path = paths[j]; + let full = path.join(" "); + for (let take = 1; take + 1 < path.length; take += 1) { + const name = nameOf(path, take); + if (!paths.some((other, k) => k !== j && nameOf(other, take) === name)) { + full = name; + break; + } + } + const leaf = path.length > 1 ? path.slice(1).join(" ") : path[0]; + const clash = paths.some( + (other, k) => + k !== j && + other[0] === path[0] && + (other.length > 1 ? other.slice(1).join(" ") : other[0]) === leaf, + ); + names.set(col.id, { full, short: clash ? full : leaf, top: path[0] }); + }); + return names; +} + +/** 식 → 읽는 글 — `[열id]` → 열 이름 · `[열id@줄]` → 이름@줄 · `[$변수]` → 변수 이름 · `*` `/` → × ÷. + * `host` 를 주면 같은 종류 열은 짧은 이름. */ +export function formulaLabel( + formula: string, + names: Map, + host?: string, +): string { + const top = host ? names.get(host)?.top : undefined; + return formula + .replace(/\[([^\]]+)\]/g, (_, ref: string) => { + const text = ref.trim(); + if (text.startsWith("$")) return text.slice(1).replace(/_/g, " "); + const [col, row] = text.split("@"); + const hit = names.get(col.trim()); + const name = !hit ? col.trim() : top !== undefined && hit.top === top ? hit.short : hit.full; + return row ? `${name}@${row.trim()}` : name; + }) + .replace(/\s*\*\s*/g, " × ") + .replace(/\s*\/\s*/g, " ÷ "); +} + +let ruler: CanvasRenderingContext2D | null = null; + +function textWidth(text: string, font: string): number { + ruler ??= document.createElement("canvas").getContext("2d"); + if (!ruler) return text.length * 13; + ruler.font = font; + return ruler.measureText(text).width; +} + +/** 열마다 머리 글 · 단위가 안 잘리는 최소 폭(px) — 여러 열을 덮는 칸은 모자란 만큼 나눠 얹음. */ +export function headMinWidths( + cols: SheetColumn[], + depth: number, + font: string, +): Map { + const mins = cols.map((c) => textWidth(c.단위 ?? "", font) + NAME_PAD); + for (const level of layoutHead(cols, depth).reverse()) { + for (const cell of level) { + const need = textWidth(cell.label, font) + NAME_PAD; + const have = mins.slice(cell.col, cell.col + cell.colspan).reduce((a, b) => a + b, 0); + if (need <= have) continue; + const extra = (need - have) / cell.colspan; + for (let k = cell.col; k < cell.col + cell.colspan; k += 1) mins[k] += extra; + } + } + return new Map(cols.map((c, j) => [c.id, Math.min(MAX_MIN_WIDTH, Math.ceil(mins[j]))])); +} diff --git a/ui_template/sheet/ui_template_sheet_ops.ts b/ui_template/sheet/ui_template_sheet_ops.ts index e9f1d59e..a4892174 100644 --- a/ui_template/sheet/ui_template_sheet_ops.ts +++ b/ui_template/sheet/ui_template_sheet_ops.ts @@ -2,6 +2,7 @@ * ui_template_sheet_ops.ts * 표 문서 고치기 — 줄 · 열 더하기 · 지우기 · 칸 값. 문서를 그 자리에서 바꿈(부른 쪽이 다시 그림). * 화면(DOM)을 모름 — 격자(`ui_template_sheet.ts`)가 부름. + * ⚠ 타입 말고는 import 하지 않음 — 시험이 Node 로 이 파일을 바로 돌림(`test_sheet_pages.py`). * ========================================================================== */ import type { SheetCell, SheetColumn, SheetDoc, SheetRow } from "./ui_template_sheet_types"; @@ -9,6 +10,7 @@ import type { SheetCell, SheetColumn, SheetDoc, SheetRow } from "./ui_template_s export type SheetMode = "master" | "project"; export const ROW_FIXED_ALL = "전구간"; +const PAGE_ROWS = 50; /** 겹치지 않는 새 id — `c1` · `c2` … */ function freshId(prefix: string, used: Iterable): string { @@ -24,6 +26,26 @@ export function orderedRows(doc: SheetDoc): SheetRow[] { return [...fixed, ...doc.줄.filter((r) => r.고정 !== ROW_FIXED_ALL)]; } +export interface SheetPage { + rows: SheetRow[]; + /** 이 쪽 첫 줄의 차례(1부터 · 쪽을 가로질러 셈). */ + first: number; + /** 합계 줄을 붙이나 — 마지막 쪽만. */ + totals: boolean; +} + +/** 쪽 나눔 — project 는 `쪽줄`(없으면 50)마다 · master 는 한 쪽. 머리는 쪽마다 그림(격자 몫). */ +export function pagePlan(doc: SheetDoc, mode: SheetMode): SheetPage[] { + const rows = orderedRows(doc); + const size = mode === "project" ? Math.max(1, doc.쪽줄 ?? PAGE_ROWS) : Math.max(1, rows.length); + const count = Math.max(1, Math.ceil(rows.length / size)); + return Array.from({ length: count }, (_, p) => ({ + rows: rows.slice(p * size, (p + 1) * size), + first: p * size + 1, + totals: p === count - 1, + })); +} + export const isCalcColumn = (col: SheetColumn): boolean => !!col.식; export const isBoundColumn = (col: SheetColumn): boolean => !!col.바인딩; diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index 25f9ddb0..2d9ab72c 100644 --- a/ui_template/sheet/ui_template_sheet_render.ts +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -9,11 +9,13 @@ import { el } from "@ui/ui_template_elements"; import { headDepth, layoutHead } from "./ui_template_sheet_header"; +import { columnNames, formulaLabel, headMinWidths } from "./ui_template_sheet_labels"; import { cellEditable, isBoundColumn, isCalcColumn, orderedRows, + pagePlan, type SheetMode, } from "./ui_template_sheet_ops"; import { groupDigits, totalFormula } from "./ui_template_sheet_recalc"; @@ -24,7 +26,6 @@ export const KEY_UNIT = "s:unit"; const MASTER_ROWS = ["s:formula", "s:desc", "s:price"] as const; const GUTTER = 64; const DEFAULT_WIDTH = { 수: 72, 글: 96 }; -const PAGE_ROWS = 50; export interface RenderState { doc: SheetDoc; @@ -33,8 +34,18 @@ export interface RenderState { sel: { r: string; c: string } | null; } -export const columnWidth = (doc: SheetDoc, col: SheetColumn): number => - doc.보기?.열너비?.[col.id] ?? DEFAULT_WIDTH[col.꼴 === "글" ? "글" : "수"]; +/** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */ +const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map): number => + Math.max( + doc.보기?.열너비?.[col.id] ?? DEFAULT_WIDTH[col.꼴 === "글" ? "글" : "수"], + mins.get(col.id) ?? 0, + ); + +/** 머리 칸 글꼴 — 폭 재기용(`.ui-sheet__table thead` 와 같게). */ +function headFont(): string { + const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + return `600 ${rem * 0.82}px ${getComputedStyle(document.body).fontFamily}`; +} /** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */ export function navRows(doc: SheetDoc, mode: SheetMode): string[] { @@ -57,9 +68,13 @@ export function keyEditable(state: RenderState, key: string, col: SheetColumn): const shown = (text: string | undefined): string => !text || text === "0" ? "" : groupDigits(text); +/** 열 딱지 — 식이 먼저(계산 열에 바인딩이 붙어 있어도 계산) · 바인딩 = 설계값(펼침이면 값마다 열) · 손 = 손 입력. */ function kindOf(col: SheetColumn): { cls: string; label: string } | null { - if (isBoundColumn(col)) return { cls: "is-bound", label: st("Kind_Bound") }; if (isCalcColumn(col)) return { cls: "is-calc", label: st("Kind_Calc") }; + if (isBoundColumn(col)) { + const label = st("Kind_Bound"); + return { cls: "is-bound", label: col.펼침 ? `${label} · ${st("Kind_Spread")}` : label }; + } if (col.손) return { cls: "is-hand", label: st("Kind_Hand") }; return null; } @@ -69,6 +84,8 @@ export function renderSheet(state: RenderState): HTMLElement { const cols = doc.열; const depth = headDepth(cols, doc.층); const head = layoutHead(cols, depth); + const names = columnNames(cols); + const mins = headMinWidths(cols, depth, headFont()); const errors = new Map(state.result.오류.map((e) => [`d:${e.줄}|${e.열}`, e.까닭])); for (const e of state.result.오류) errors.set(`t:${e.줄}|${e.열}`, e.까닭); const frozen = Math.min(doc.보기?.틀고정?.열 ?? 0, cols.length); @@ -76,7 +93,7 @@ export function renderSheet(state: RenderState): HTMLElement { let left = GUTTER; for (const col of cols) { lefts.push(left); - left += columnWidth(doc, col); + left += columnWidth(doc, col, mins); } const freeze = (cell: HTMLElement, from: number, span = 1): void => { @@ -133,19 +150,34 @@ export function renderSheet(state: RenderState): HTMLElement { cols.forEach((col, j) => { const text = key === "s:formula" - ? (col.식 ?? "") + ? formulaLabel(col.식 ?? "", names, col.id) : key === "s:desc" - ? (col.설명 ?? "") + ? "" : (col.일위대가 ?? ""); const td = bodyCell(key, col, j, text); td.classList.remove("is-num"); + if (key === "s:formula" && col.식) { + // 같은 종류 열은 짧은 이름 · 풍선은 긴 이름 + 고칠 때 쓰는 id 식 + td.textContent = ""; + td.append(el("div", { className: "ui-sheet__clamp", text })); + td.title = `${formulaLabel(col.식, names)}\n${col.식}`; + } const kind = kindOf(col); - if (key === "s:desc" && kind) { - const chip = el("span", { - className: `ui-sheet__chip ${kind.cls}`, - text: col.펼침 ? `${kind.label} · ${st("Kind_Spread")}` : kind.label, - }); - td.prepend(chip); + if (key === "s:desc") { + // 두세 줄로 자름 — 전문은 마우스를 올리면(title) + const desc = col.설명 ?? ""; + td.title = desc; + td.append( + el("div", { + className: "ui-sheet__clamp", + children: [ + ...(kind + ? [el("span", { className: `ui-sheet__chip ${kind.cls}`, text: kind.label })] + : []), + desc, + ], + }), + ); } tr.append(td); }); @@ -159,6 +191,7 @@ export function renderSheet(state: RenderState): HTMLElement { const th = el("th", { text: cell.label, attrs: { + title: cell.label, colspan: String(cell.colspan), rowspan: String(cell.rowspan), "data-level": String(cell.level), @@ -189,7 +222,8 @@ export function renderSheet(state: RenderState): HTMLElement { const tr = el("tr", { className: "ui-sheet__total", children: [gutter("th", total.이름)] }); cols.forEach((col, j) => { const td = bodyCell(`t:${total.id}`, col, j, shown(state.result.합계[total.id]?.[col.id])); - if (totalFormula(total, col)) td.title = totalFormula(total, col)!; + const formula = totalFormula(total, col); + if (formula) td.title = formulaLabel(formula, names); tr.append(td); }); return tr; @@ -199,22 +233,22 @@ export function renderSheet(state: RenderState): HTMLElement { children: [ el("col", { attrs: { style: `width:${GUTTER}px` } }), ...cols.map((col) => - el("col", { attrs: { style: `width:${columnWidth(doc, col)}px`, "data-col": col.id } }), + el("col", { + attrs: { style: `width:${columnWidth(doc, col, mins)}px`, "data-col": col.id }, + }), ), ], }); - const data = orderedRows(doc); - const size = mode === "project" ? Math.max(1, doc.쪽줄 ?? PAGE_ROWS) : Math.max(1, data.length); - const pageCount = Math.max(1, Math.ceil(data.length / size)); + const plan = pagePlan(doc, mode); + const pageCount = plan.length; const pages: HTMLElement[] = []; - for (let p = 0; p < pageCount; p += 1) { - const slice = data.slice(p * size, (p + 1) * size); + for (const [p, page] of plan.entries()) { const body = el("tbody", { children: [ ...(mode === "master" && p === 0 ? MASTER_ROWS.map(masterRow) : []), - ...slice.map((row, i) => dataRow(row, p * size + i + 1)), - ...(p === pageCount - 1 ? totalRows() : []), + ...page.rows.map((row, i) => dataRow(row, page.first + i)), + ...(page.totals ? totalRows() : []), ], }); const table = el("table", { From 07953fa5238089135dc363e4e14eebb2b7085fef Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 25 Sep 2026 12:03:10 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(M02):=20=EC=98=9B=20=ED=91=9C=20?= =?UTF-8?q?=ED=8B=80=20=EC=83=88=20=ED=8C=90=EC=9C=BC=EB=A1=9C=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=E2=80=94=20Migrate=20--refresh-tables=20=C2=B7=20?= =?UTF-8?q?=EC=86=90=20=EA=B0=92=20=C2=B7=20=EB=B3=80=EC=88=98=20=C2=B7=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EC=A7=80=ED=82=B4=20=C2=B7=20=5Finitial?= =?UTF-8?q?=20=EB=8F=84=20=EA=B0=99=EC=9D=80=20=ED=8C=90=20(=EB=B8=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B8=20=EC=B5=9C=EC=A2=85=20=ED=99=95=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 계산 열에 바인딩이 붙은 옛 틀만 골라 시스템 새 판으로 - 손댄 것이 없으면 시스템 파일 그대로 복사(판이 시스템과 같음) · 있으면 고쳐 쓰고 manifest 에 출처 판 - 기존 프로젝트 6개 작업본 · _initial 갱신 완료 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LuapLYqN1GGFD8Y1PStD5 --- M02_MasterTemplete/M02_Template_Layers.py | 9 +++ M02_MasterTemplete/M02_Template_Migrate.py | 70 +++++++++++++++++--- resources/tester/test_m02_template_layers.py | 41 ++++++++++++ 3 files changed, 112 insertions(+), 8 deletions(-) diff --git a/M02_MasterTemplete/M02_Template_Layers.py b/M02_MasterTemplete/M02_Template_Layers.py index 2e1fb345..82adfe26 100644 --- a/M02_MasterTemplete/M02_Template_Layers.py +++ b/M02_MasterTemplete/M02_Template_Layers.py @@ -209,6 +209,15 @@ def _write_manifest(layer_dir: str | Path, entries: dict[str, Any]) -> None: atomic_write_json(Path(layer_dir) / MANIFEST_NAME, {"양식": entries}) +def note_source( + layer_dir: str | Path, kind: str, name: str, source_layer: str, version: str +) -> None: + """manifest 한 줄 — 복사가 아니라 고쳐 쓴 양식의 출처 · 판.""" + manifest = read_manifest(layer_dir) + manifest[f"{kind}/{name}"] = _stamp(source_layer, name, version) + _write_manifest(layer_dir, manifest) + + def _stamp(layer: str, name: str, version: str | None, **extra: Any) -> dict[str, Any]: entry = { "층": layer, diff --git a/M02_MasterTemplete/M02_Template_Migrate.py b/M02_MasterTemplete/M02_Template_Migrate.py index 652fe2ae..294cdd08 100644 --- a/M02_MasterTemplete/M02_Template_Migrate.py +++ b/M02_MasterTemplete/M02_Template_Migrate.py @@ -3,14 +3,17 @@ 프로젝트마다 `seed_project(only_missing=True)` — 작업본 · `_initial/` 에 **없는 것만 더함**. 이미 있는 양식 · 다른 파일은 안 건드림 · 폴더가 없는 프로젝트는 건너뜀(만들지 않음). 다시 돌려도 됨 — 시스템 양식이 늘었으면 그 몫만 더해짐. +`--refresh-tables` — 옛 표 틀(계산 열에 바인딩)만 새 판으로 · 손 값은 지킴. - ./venv/Scripts/python.exe M02_MasterTemplete/M02_Template_Migrate.py [--dry-run] [--report 파일] + ./venv/Scripts/python.exe M02_MasterTemplete/M02_Template_Migrate.py \\ + [--dry-run] [--refresh-tables] [--report 파일] """ from __future__ import annotations import argparse import asyncio +import copy import os import sys from datetime import datetime @@ -21,6 +24,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from config import config_system # noqa: E402 from config.config_db import close_db_pool, get_db_pool, init_db_pool # noqa: E402 +from M02_MasterTemplete import M02_Table_Fill as fill # noqa: E402 from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402 @@ -51,13 +55,62 @@ def project_root(storage_path: str | None) -> Path | None: return path -def migrate(projects: list[dict[str, Any]], *, dry_run: bool = False) -> list[dict[str, Any]]: - """프로젝트마다 넣은 것 `[{…프로젝트, 작업본, 초기, 상태}]`.""" +def _stale_frame(document: Any) -> bool: + """옛 표 틀 — 계산 열에 바인딩(설계값 딱지)이 붙은 판.""" + return isinstance(document, dict) and any( + isinstance(column, dict) + and column.get("바인딩") + and (column.get("식") or column["바인딩"].get("값") == "식") + for column in document.get("열") or [] + ) + + +def refresh_tables(root: Path, *, dry_run: bool = False) -> list[str]: + """옛 표 틀을 시스템 새 판으로 — 작업본 · `_initial/` 둘 다 · 줄(손 값) · 변수 · 보기는 지킴. + + 손댄 것이 없으면 시스템 파일을 그대로 복사(판이 시스템과 같음). + """ + done: list[str] = [] + for row in layers.list_templates(layers.system_dir()): + if row["종류"] != "table": + continue + system = layers.read_template(layers.system_dir(), "table", row["이름"]) + for folder in (layers.project_dir(root), layers.initial_dir(root)): + got = layers.read_template(folder, "table", row["이름"]) + if not system or not got or not _stale_frame(got["문서"]): + continue + done.append(f"{folder.name}/table/{row['이름']}") + if dry_run: + continue + old = fill.strip_design(got["문서"]) + doc = copy.deepcopy(system["문서"]) + doc["변수"] = {**(doc.get("변수") or {}), **(old.get("변수") or {})} + doc.update({key: old[key] for key in ("줄", "보기") if key in old}) + if doc == system["문서"]: + layers.copy_templates( + layers.system_dir(), + folder, + source_layer="system", + kind="table", + name=row["이름"], + ) + else: + layers.write_template(folder, "table", row["이름"], doc) + layers.note_source(folder, "table", row["이름"], "system", system["판"]) + return done + + +def migrate( + projects: list[dict[str, Any]], *, dry_run: bool = False, refresh: bool = False +) -> list[dict[str, Any]]: + """프로젝트마다 넣은 것 `[{…프로젝트, 작업본, 초기, 표갱신, 상태}]`.""" system = {f"{row['종류']}/{row['이름']}" for row in layers.list_templates(layers.system_dir())} report = [] for project in projects: root = project_root(project.get("storage_path")) - entry = {**project, "작업본": [], "초기": [], "상태": ""} + entry = {**project, "작업본": [], "초기": [], "표갱신": [], "상태": ""} + if root is not None and refresh: + entry["표갱신"] = refresh_tables(root, dry_run=dry_run) if root is None: entry["상태"] = "폴더 없음 — 건너뜀" elif dry_run: @@ -85,14 +138,14 @@ def render(report: list[dict[str, Any]], system: list[dict[str, Any]]) -> str: "- 방법: 작업본 · `_initial/` 에 없는 것만 더함 · 있는 양식 · 다른 파일은 그대로", f"- 프로젝트 {len(report)}개 · 넣음 {sum(r['상태'] == '넣음' for r in report)}개", "", - "| 회사 | 만든 사람 | 프로젝트 | ID | 넣은 양식 | 상태 |", - "| --- | --- | --- | --- | --- | --- |", + "| 회사 | 만든 사람 | 프로젝트 | ID | 넣은 양식 | 표 틀 새 판 | 상태 |", + "| --- | --- | --- | --- | --- | --- | --- |", ] for row in report: added = " · ".join(row["작업본"]) or "—" lines.append( f"| {row['company_id']} | {row.get('owner') or row['user_id']} | {row['name']} " - f"| `{row['id']}` | {added} | {row['상태']} |" + f"| `{row['id']}` | {added} | {' · '.join(row['표갱신']) or '—'} | {row['상태']} |" ) return "\n".join(lines) + "\n" @@ -100,6 +153,7 @@ def render(report: list[dict[str, Any]], system: list[dict[str, Any]]) -> str: async def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--refresh-tables", action="store_true") parser.add_argument("--report", type=Path) args = parser.parse_args() await init_db_pool() @@ -107,7 +161,7 @@ async def main() -> None: projects = await _projects() finally: await close_db_pool() - report = migrate(projects, dry_run=args.dry_run) + report = migrate(projects, dry_run=args.dry_run, refresh=args.refresh_tables) text = render(report, layers.list_templates(layers.system_dir())) if args.report: args.report.write_text(text, encoding="utf-8") diff --git a/resources/tester/test_m02_template_layers.py b/resources/tester/test_m02_template_layers.py index 52d2b591..e1d56911 100644 --- a/resources/tester/test_m02_template_layers.py +++ b/resources/tester/test_m02_template_layers.py @@ -295,3 +295,44 @@ def test_프로젝트_표_저장은_서버가_설계값을_걸러_씀(world: dic stored = layers.read_template(_root(world, P1) / "templates", "table", "구조물집계표")["문서"] assert stored["줄"][1] == {"id": "s40.00", "값": {"sta": "NO.2", "h_intake": 2}} assert "알림" not in stored and all("|" not in c["id"] for c in stored["열"]) + + +def test_옛_표_틀은_새_판으로_손_값은_지킴(world: dict[str, Any]) -> None: + from M02_MasterTemplete import M02_Template_Migrate as migrate + + new = { + "판": 2, + "열": [{"id": "a", "식": "1", "펼침틀": {"묶음": "g"}}], + "줄": [], + "변수": {"k": 1}, + } + old = { + "판": 1, + "열": [{"id": "a", "식": "1", "바인딩": {"종류": "pipe", "값": "식"}}, {"id": "memo"}], + "줄": [{"id": "s10.00", "값": {"sta": "NO.0+10", "memo": "손"}}], + "변수": {"k": 5}, + } + layers.write_template(layers.system_dir(), "table", "구조물집계표", new) + root2, root1 = _root(world, P2, user=43), _root(world, P1) + for folder in (root1 / "templates", root1 / "templates/_initial", root2 / "templates"): + layers.write_template(folder, "table", "구조물집계표", old) + layers.write_template( + root2 / "templates", "table", "구조물집계표", {**old, "줄": [], "변수": {"k": 1}} + ) + + assert migrate.refresh_tables(root1, dry_run=True) == [ + "templates/table/구조물집계표", + "_initial/table/구조물집계표", + ] + migrate.refresh_tables(root1) + work = layers.read_template(root1 / "templates", "table", "구조물집계표")["문서"] + assert work["열"] == new["열"] and work["변수"] == {"k": 5} # 틀만 새 판 · 변수 · 손 값 지킴 + assert work["줄"] == old["줄"] + system_version = layers.version_of(layers.system_dir() / "table/구조물집계표.json") + initial = root1 / "templates/_initial" + assert layers.version_of(initial / "table/구조물집계표.json") != system_version # 변수 다름 + assert layers.read_manifest(root1 / "templates")["table/구조물집계표"]["판"] == system_version + assert migrate.refresh_tables(root1) == [] # 다시 돌려도 그대로 + # 손댄 것 없는 작업본은 시스템 파일 그대로(판이 같음) + migrate.refresh_tables(root2) + assert layers.version_of(root2 / "templates/table/구조물집계표.json") == system_version