From b87bac729b5b73e0253721527bb0701611de6027 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 25 Sep 2026 10:12:55 +0900 Subject: [PATCH] =?UTF-8?q?fix(M02):=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=ED=91=9C=20=EC=A0=80=EC=9E=A5=EC=9D=80=20=EC=96=91?= =?UTF-8?q?=EC=8B=9D=20+=20=EC=86=90=20=EA=B0=92=EB=A7=8C=20=E2=80=94=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=EA=B0=92=20=C2=B7=20=ED=8E=BC=EC=B9=9C=20?= =?UTF-8?q?=EC=97=B4=EC=9D=80=20=EC=84=9C=EB=B2=84=EA=B0=80=20=EA=B1=B8?= =?UTF-8?q?=EB=9F=AC=20=EB=B2=84=EB=A6=BC=20=C2=B7=20=EC=97=86=EC=96=B4?= =?UTF-8?q?=EC=A7=84=20=EC=B8=A1=EC=A0=90=20=EC=86=90=20=EA=B0=92=EC=9D=80?= =?UTF-8?q?=20=EB=A7=A8=20=EC=95=84=EB=9E=98=20=EB=B9=84=EA=B3=A0=20?= =?UTF-8?q?=EC=A4=84=20(PLAN=2010-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LuapLYqN1GGFD8Y1PStD5 --- .../M02_MasterTemplete_Router_Layers.py | 6 +- M02_MasterTemplete/M02_Table_Fill.py | 75 ++++++++++++++++++- resources/tester/test_m02_table_fill.py | 35 +++++++++ resources/tester/test_m02_template_layers.py | 30 ++++++++ 4 files changed, 142 insertions(+), 4 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index 27aaade5..151d3ec6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -252,13 +252,17 @@ async def save_layer_template( session: dict[str, Any] = Depends(verify_session), ) -> dict[str, Any]: folder = await _layer_dir(session, layer, project_id=project_id, write=True) + document = body.문서 + if kind == "table" and fill.is_fillable(document): + # 채운 표가 와도 설계값은 안 받아 적음 — 양식 + 손 값만(5장 · 브라우저 값을 믿지 않음) + document = fill.strip_design(document) try: version = await asyncio.to_thread( layers.write_template, folder, kind, name, - body.문서, + document, version=body.판, check_version=True, ) diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index 71790d56..d5f32951 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -36,6 +36,9 @@ FACILITY_TYPES = { "revetment": "revetment", } EMPTY_LABEL = "-" +ORPHAN_NOTE = "설계에서 측점 없어짐" +#: 채운 표에 실어 보내는 펼침 전 열 — 저장 때 빠진 규칙 열을 되살리는 데만 씀. +RULE_COLUMNS_KEY = "양식열" _REF = re.compile(r"\[([^\]$@][^\]@]*)\]") @@ -354,7 +357,8 @@ def fill_document( doc = copy.deepcopy(document) variables = dict(doc.get("변수") or {}) interval = _number(variables.get("측점간격_m")) or 20.0 - columns, made = _expand_columns(doc.get("열") or [], items, variables, _choices(types)) + rule_columns = _unexpand(doc.get("열") or []) + columns, made = _expand_columns(rule_columns, items, variables, _choices(types)) notes: list[str] = [] # 줄 자리 — 관은 한 줄에 하나(둘째 관부터 줄을 나눔) @@ -411,7 +415,7 @@ def fill_document( notes.append(f"가드레일·경계석·위험표지 종류 미정 {loose}건 — 표에 안 셈") # 손 값 지키기 — 같은 줄 id 의 손 열 · 전구간 줄 · 사용자가 더한 줄 - hand = {column["id"] for column in columns if column.get("손")} + hand = _hand_ids(columns) old_rows = {row.get("id"): row for row in doc.get("줄") or []} fixed = [row for row in doc.get("줄") or [] if row.get("고정")] added = [row for row in doc.get("줄") or [] if row.get("손") and not row.get("고정")] @@ -438,13 +442,78 @@ def fill_document( } if values: sta = (row.get("값") or {}).get("sta") - added.append({"id": row["id"], "값": {"sta": sta, **values}, "손": True}) + memo = " · ".join(filter(None, [str(values.get("memo") or ""), ORPHAN_NOTE])) + added.append({"id": row["id"], "값": {"sta": sta, **values, "memo": memo}, "손": True}) doc["열"] = columns doc["줄"] = [*fixed, *design_rows, *added] doc["알림"] = notes + doc[RULE_COLUMNS_KEY] = copy.deepcopy(rule_columns) # 저장 때 설계에 안 쓰인 펼침 열도 되살림 return doc +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") + } + + +def _restore_rules( + columns: list[dict[str, Any]], rules: list[dict[str, Any]] +) -> list[dict[str, Any]]: + """채울 때 설계 값이 없어 빠졌던 펼침 열을 원래 자리(앞 열 뒤)에 되살림.""" + out = list(columns) + for index, rule in enumerate(rules): + if not rule.get("펼침") or any(c["id"] == rule["id"] for c in out): + continue + before = [r["id"] for r in rules[:index]] + at = max((i + 1 for i, c in enumerate(out) if c["id"] in before), default=0) + out.insert(at, copy.deepcopy(rule)) + return out + + +def strip_design(document: dict[str, Any]) -> dict[str, Any]: + """작업본에 둘 것만 — 양식(열 · 식 · 변수 · 머리) + 손 값 + 전구간 줄 · 사용자 줄. + + 채운 표가 [저장]으로 와도 설계값 · 펼친 열은 버림(펼침 규칙 열 하나로 되돌림) · + 측점 줄은 손 값이 있을 때만 `{id(측점 키), sta, 손 값}` 으로 남김 — + 다시 채우면 같은 측점에 붙음. + """ + doc = copy.deepcopy(document) + for key in ("알림", "결과"): + doc.pop(key, None) + columns = _restore_rules(_unexpand(doc.get("열") or []), doc.pop(RULE_COLUMNS_KEY, None) or []) + hand = _hand_ids(columns) + rows = [] + for row in doc.get("줄") or []: + if row.get("고정"): + rows.append(row) + continue + values = row.get("값") or {} + kept = {k: v for k, v in values.items() if k in hand and v not in (None, "")} + if row.get("손"): + formulas = {k: v for k, v in (row.get("식") or {}).items() if k in hand} + new = {**row, "값": {"sta": values.get("sta"), **kept}} + new.pop("식", None) + if formulas: + new["식"] = formulas + rows.append(new) + elif kept: + rows.append({"id": row["id"], "값": {"sta": values.get("sta"), **kept}}) + doc["열"] = columns + doc["줄"] = rows + return doc + + +def is_fillable(document: Any) -> bool: + """설계값을 받는 표인가 — 바인딩 열이 하나라도 있으면.""" + return isinstance(document, dict) and any( + isinstance(column, dict) and column.get("바인딩") for column in document.get("열") or [] + ) + + def fill_table( project_root: str | Path, document: dict[str, Any], diff --git a/resources/tester/test_m02_table_fill.py b/resources/tester/test_m02_table_fill.py index 6d390bbd..bffc7425 100644 --- a/resources/tester/test_m02_table_fill.py +++ b/resources/tester/test_m02_table_fill.py @@ -148,3 +148,38 @@ def test_측점_표기() -> None: assert fill.station_label(38) == "NO.1+18" assert fill.station_label(59.999) == "NO.3" assert fill.station_label(2413.5) == "NO.120+13.5" + + +def test_저장은_양식과_손_값만_다시_채우면_제자리(project: Path) -> None: + master = _master() + doc = _filled(project) + rows = {row["id"]: row for row in doc["줄"]} + rows["s300.00"]["값"]["h_intake"] = 2 + rows["s85.05#1"]["값"]["memo"] = "흄관 확인" + doc["줄"][0]["값"]["ps"] = 3 + saved = fill.strip_design({**doc, "결과": {"계산": {}}}) + assert [c["id"] for c in saved["열"]] == [ + c["id"] for c in master["열"] + ] # 펼침 규칙 열로 되돌림 + assert "알림" not in saved and "결과" not in saved + assert saved["줄"] == [ + {"id": "all", "값": {"sta": "전구간", "ps": 3}, "고정": "전구간"}, + {"id": "s85.05#1", "값": {"sta": "NO.4+5.05", "memo": "흄관 확인"}}, + {"id": "s300.00", "값": {"sta": "NO.15", "h_intake": 2}}, + ] + again = {row["id"]: row for row in _filled(project, saved)["줄"]} + assert again["s300.00"]["값"]["h_intake"] == 2 and again["s300.00"]["값"]["ps"] == 1 + assert again["s85.05#1"]["값"]["memo"] == "흄관 확인" + assert again["s85.05#1"]["값"]["pp_len|흄관|1000"] == 14.0 + + +def test_측점이_없어진_손_값은_맨_아래_비고_줄로(project: Path) -> None: + saved = fill.strip_design(_filled(project)) + saved["줄"].append({"id": "s300.00", "값": {"sta": "NO.15", "h_intake": 2}}) + saved["줄"].append({"id": "s555.00", "값": {"sta": "NO.27+15", "h_intake": 1}}) + doc = _filled(project, saved) + last = doc["줄"][-1] + assert last["id"] == "s555.00" and last["손"] is True + assert last["값"] == {"sta": "NO.27+15", "h_intake": 1, "memo": "설계에서 측점 없어짐"} + kept = fill.strip_design(doc) + assert kept["줄"][-1]["값"]["h_intake"] == 1 # 다음 저장에도 남음 diff --git a/resources/tester/test_m02_template_layers.py b/resources/tester/test_m02_template_layers.py index 5c71d0ca..52d2b591 100644 --- a/resources/tester/test_m02_template_layers.py +++ b/resources/tester/test_m02_template_layers.py @@ -265,3 +265,33 @@ def test_옛_프로젝트_넣기_도구는_더하기만_폴더_없으면_건너 assert not (world["storage"] / "7/42/gone").exists() assert migrate.migrate(projects)[0]["상태"] == "이미 있음" assert "| 7 | 42 | 옛 |" in migrate.render(list(report.values()), []) + + +def test_프로젝트_표_저장은_서버가_설계값을_걸러_씀(world: dict[str, Any]) -> None: + from M02_MasterTemplete import M02_Table_Fill as fill + + client = world["client"] + master = json.loads( + ( + config_system.PROJECT_ROOT / "resources/master_template/table/구조물집계표.json" + ).read_text(encoding="utf-8") + ) + filled = fill.fill_document( + master, + [ + { + "type_id": "position_sign", + "placement": "point", + "chainage_m": 40.0, + "start_m": None, + "end_m": None, + "options": {}, + } + ], + ) + filled["줄"][1]["값"]["h_intake"] = 2 + version = client.get(_url()).json()["판"] + assert client.put(_url(), json={"판": version, "문서": filled}).status_code == 200 + 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["열"])