fix(M02): 구조물집계표 묶음 열은 값마다 한데 · 빈 설계값 머리는 「높이 미입력」 꼴 · 계산 열 설계값 딱지 뗌 (브레인 최종 확인)

- 관공 등 묶음 열 차례 = 관종·관경마다 관매설 · 커플링 짝 — 머리 합치기가 값마다 묶임
- 머리틀은 맨 자리표 + 값꼴(H={} · T={}cm · Φ{}) · 빈 값은 빈값 글 · 열 id 는 그대로(빈 값 -)
- 포장 A · 수축줄눈 · 커플링 · 큰돌쌓기 면적 = 바인딩 대신 펼침틀(묶음 · 머리틀 · 식틀)
- 식틀 자리 값이 비면 식을 빼고 설명에 적음

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 12:00:54 +09:00
co-authored by Claude Opus 5.5
parent d6302a9695
commit abd0d4b126
3 changed files with 143 additions and 73 deletions
+89 -46
View File
@@ -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")
}