fix(M02): 프로젝트 표 저장은 양식 + 손 값만 — 설계값 · 펼친 열은 서버가 걸러 버림 · 없어진 측점 손 값은 맨 아래 비고 줄 (PLAN 10-4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LuapLYqN1GGFD8Y1PStD5
This commit is contained in:
2026-09-25 10:12:55 +09:00
co-authored by Claude Opus 5.5
parent 9ca31ef7e8
commit b87bac729b
4 changed files with 142 additions and 4 deletions
@@ -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,
)
+72 -3
View File
@@ -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],
+35
View File
@@ -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 # 다음 저장에도 남음
@@ -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["열"])