Merge remote-tracking branch 'origin/sub_laptop_8' into sub_desktop_1

This commit is contained in:
2026-10-09 15:01:59 +09:00
23 changed files with 672 additions and 221 deletions
@@ -12,7 +12,9 @@ Q · L 출처 = 저장된 유토곡선 배분(`mass_haul.haul_plan` — B07 토
(`free_haul` = 무대 · `dozer` = 불도우저 · `dump_truck` = 덤프트럭).
암 갈래 — 「연암」 · 「경암」 줄 = 암 전체 × 비율(좌측 패널 · 기본 90 : 10 · PLAN 122).
풍화암 · 보통암 · 구조물 쪽 줄(su2 시트)은 값이 없어 비움(None).
풍화암 · 보통암 · 표토제거 · 법면보호공 · 운반성토 줄은 값이 없어 비움(None) · 구조물 쪽 줄(su2 시트
구조물터파기 · 토사다짐 · 되메우기 · 잔토)은 뺌 · 거창 계수(성토면다짐 0.8 · 초류종자 성토 0.5 ·
지장목 0.8)는 근거가 없어 값 빈칸 + 「비율 미정」(PLAN 143-2).
짝 — `rock_split` · `split_rock_columns` ↔ `B08_Quantity_Options_Calc.ts` `rockSplit` ·
`splitRockColumns` · `splitRockSummary`(거울 시험 `test_122_b08_options_mirror`).
@@ -99,8 +101,8 @@ def summary_rows(
r0 = excel_round
free_q, dump_q, dozer_q = haul["free_haul"]["q"], haul["dump_truck"]["q"], haul["dozer"]["q"]
diverted = t["diverted_m3"]
tree = r0(t["tree_removal_fill_area_m2"] * 0.5 + t["tree_removal_cut_area_m2"], 2)
m3 = "㎥"
unsure = "비율 미정" # 거창 계수 자리 — 값을 확인한 뒤 다시 잇기(143-2)
return [
_row("흙깍기", "토사", "기계(굴삭기)", m3, r0(t["cut_soil_volume_m3"])), # 토적표!E137
_row("흙깍기", "풍화암", "굴삭기 + 브레카", m3, None),
@@ -121,26 +123,10 @@ def summary_rows(
_row("토사운반", "", "19 TON B/D", m3, dozer_q), # 평균운반거리 불도우저 ΣQ
_row("잔토처리", "", "백호우", m3, r0(t["balance_m3"])), # 토적표!T137
_row("층따기", "", "백호우", m3, t["benching_area_m2"]), # 토적표!W137
_row("성토면다짐", "", "", "㎡", r0(t["grading_fill_area_m2"] * 0.8), "성토면80%반영"),
_row("성토면다짐", "", "", "㎡", None, unsure), # 거창 = 성토면 × 0.8
_row("표토제거", "", "", "㎡", None),
_row(
"초류종자살포",
"",
"씨드스프레이",
"㎡",
r0(t["grading_fill_area_m2"] * 0.5 + t["grading_cut_area_m2"]),
"성토면50%반영+절토면",
),
_row("초류종자살포", "", "씨드스프레이", "㎡", None, unsure), # 거창 = 성토 × 0.5 + 절토
_row("법면보호공", "", "", "㎡", None),
# 토적표!AI138 = ROUND(성토 × 0.5 + 절토, 2) → × 0.8
_row("지장목제거", "", "", "㎡", r0(tree * 0.8), "80%반영"),
_row("구조물터파기", "토사", "기계(굴삭기)", m3, None, "구조물 수량"),
_row("구조물터파기", "풍화암", "굴삭기 + 브레카", m3, None),
_row("구조물터파기", "연암", "굴삭기 + 브레카", m3, None, "구조물 수량"),
_row("구조물터파기", "보통암", "굴삭기 + 브레카", m3, None),
_row("구조물터파기", "경암", "발파 + 굴삭기", m3, None),
_row("토사다짐", "", "", m3, None, "구조물 수량"),
_row("되메우기", "", "", m3, None, "구조물 수량"),
_row("잔토", "", "", m3, None, "현장내 성토(유용토)"),
_row("지장목제거", "", "", "㎡", None, unsure), # 거창 = (성토 × 0.5 + 절토) × 0.8
_row("운반성토", "", "BH+DT+BH", m3, None),
]
+2 -2
View File
@@ -102,8 +102,8 @@ def compute(inputs: dict[str, Any]) -> dict[str, Any]:
props = section_props(inputs["section"], diameter_mm / 1000.0) if diameter_mm else {}
elif sheet == "box":
width, height = _num(inputs, "box_width_m"), _num(inputs, "box_height_m")
ratio = _num(inputs, "depth_ratio") or SHEET_DEFAULTS["box"]["depth_ratio"]
if width and height:
ratio = _num(inputs, "depth_ratio") # 빈칸 = 수심 · 단면 · 유출량 멈춤(143-2)
if width and height and ratio:
depth = ratio * height
props = rect_props(width, depth)
else:
+3 -2
View File
@@ -56,10 +56,11 @@ SHEETS = {
"ford_pavement": ("ford", "물넘이포장"),
}
#: 장별 처음 값 — 박스 수심비 0.8H · n 0.013(울진 산출서 · 조사 69-7) · 내공 · 경사는 빈칸
#: 장별 처음 값 — 박스 n 0.013(KDS 콘크리트관) · 수심비 · 내공 · 경사는 빈칸(수심비 0.8 = 울진 산출서 —
#: 근거 없어 안 채움 · 빈칸이면 단면 계산 멈춤 · 143-2)
#: 세월교 · 물넘이 월류 폭 10 m(사용자 10-7) · n · 경사 = B05 세월교 높이 계산과 같은 값
SHEET_DEFAULTS: dict[str, dict[str, Any]] = {
"box": {"n": 0.013, "depth_ratio": 0.8, "box_width_m": None, "box_height_m": None},
"box": {"n": 0.013, "depth_ratio": None, "box_width_m": None, "box_height_m": None},
"ford": {
"n": FORD_MANNING_N,
"overflow_width_m": 10.0,
+98 -47
View File
@@ -8,7 +8,8 @@
· 길이 = 전 + 후 · 빈 높이 = 횡단도가 그리는 관경 최소 높이
돌붙임 › 찰붙임 · 메붙임 ㎡ 도수로/돌붙임 붙임 형식 · 면적(빈칸이면 안 셈 · 98-3)
관 공 › Φ{관경}m/m › 관매설 m B06 관 길이(1 m 올림 그대로)
› 커플링 ea 올림(L ÷ 1본) − 1 · 1본 = 프로젝트 층 M02 구조물집계표 변수
› 커플링 ea 올림(L ÷ 1본) − 1 · 1본 = 프로젝트 층 M02 구조물집계표 변수만
관경 · 련 빈칸 = 「미입력」 열(임의 값으로 안 채움 · 143-2)
› 관보호공 모양 실제로 선 집수정 모양마다 1 개소(98-2 `inlet_basin`)
BOX암거 › {가로×세로} › 본체 m B06 새 키 `box_length_m`(두 끝 offset 차 · 120)
› 날개벽 › 유입 · 유출 개소
@@ -46,6 +47,7 @@ from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
from common_util.common_util_summary_item import GROUP_JOIN
from config.config_db import get_db_pool
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
from M02_MasterTemplete import M02_Table_Fill as fill
from M02_MasterTemplete import M02_Template_Layers as layers
from M02_MasterTemplete.M02_Table_Fill_Spec import soil_tiers_from_designs
@@ -85,6 +87,23 @@ def _num(value: Any) -> float | None:
return None
def _count(options: dict[str, Any], key: str) -> int | None:
"""관경 · 련 수 — 빈칸이면 None(「미입력」 열 · 임의 값으로 안 채움 · 143-2)."""
value = _num(options.get(key))
return int(value) if value else None
def _pipe_head(names: dict[str, str], diameter: int | None) -> list[str]:
"""「관 공」 머리 — 관경 빈칸이면 「미입력」."""
if diameter is None:
return ["관 공", fill.EMPTY_TEXT]
return ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"]
def _key(value: int | None) -> str:
return fill.EMPTY_LABEL if value is None else str(value)
def _combo(item: Any) -> str | None:
"""집계 항목 → 조합 id(화면 `comboId` 와 같은 꼴) · 없으면 None."""
if not isinstance(item, dict) or not item.get("key"):
@@ -93,13 +112,10 @@ def _combo(item: Any) -> str | None:
def one_pipe_length(project_root: str | Path) -> float | None:
"""1본 길이(m) — 프로젝트 층 구조물집계표 변수 · 없으면 시스템 층 · 빈칸이면 None."""
for folder in (layers.project_dir(project_root), layers.system_dir()):
found = layers.read_template(folder, "table", TABLE_NAME)
if found is not None:
value = _num((found["문서"].get("변수") or {}).get(ONE_LENGTH_VAR))
return value if value and value > 0 else None
return None
"""1본 길이(m) — 프로젝트 층 구조물집계표 변수만(시스템 층으로 안 채움 · 143-2) · 빈칸 None."""
found = layers.read_template(layers.project_dir(project_root), "table", TABLE_NAME)
value = _num((found["문서"].get("변수") or {}).get(ONE_LENGTH_VAR)) if found else None
return value if value and value > 0 else None
def couplings(length_m: float | None, one_m: float | None) -> int | None:
@@ -159,6 +175,8 @@ class _Sheet:
def __init__(self) -> None:
self.columns: dict[str, dict[str, Any]] = {}
self.rows: dict[int, dict[str, Any]] = {}
self.owner = "" # 지금 셈하는 시설 — 사본 없어 안 센 칸의 시설을 모음(143-1)
self.unlinked: set[str] = set()
def add(
self,
@@ -170,6 +188,9 @@ class _Sheet:
value: float | None,
combos: list[str | None],
) -> None:
if not any(combos): # 그 칸을 낸 조합 사본이 없음 — 안 셈(143-1)
self.unlinked.add(self.owner)
return
cid = f"pipe:{key}"
column = self.columns.setdefault(
cid, {"id": cid, "group": group, "label": label, "combos": [], "_sort": sort}
@@ -338,10 +359,11 @@ def _ford(
combos: dict[str, str | None],
names: dict[str, str],
) -> None:
"""세월교 — Φ관경×n련마다 월류 폭 m · 본체 개소."""
diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000)
count = int(_num(base["options"].get("pipe_count")) or 1)
spec = f"Φ{names.get(f'ford.diameter={diameter}', diameter)}×{count}련"
"""세월교 — Φ관경×n련마다 월류 폭 m · 본체 개소(관경 · 련 빈칸 = 「미입력」)."""
diameter = _count(base["options"], "pipe_diameter_mm")
count = _count(base["options"], "pipe_count")
size = fill.EMPTY_TEXT if diameter is None else names.get(f"ford.diameter={diameter}", diameter)
spec = f"Φ{size}×{fill.EMPTY_TEXT if count is None else count}련"
width_name = names.get("ford.width", "월류 폭").split(" (")[0]
cells = (
("width", width_name, "m", _num(base["options"].get("ford_width_m"))),
@@ -350,8 +372,8 @@ def _ford(
for index, (key, label, unit, value) in enumerate(cells):
sheet.add(
at,
f"ford|{diameter}|{count}|{key}",
(4, 0, diameter, count, index),
f"ford|{_key(diameter)}|{_key(count)}|{key}",
(4, 0, diameter or math.inf, count or math.inf, index),
[names["종류"], spec, label],
unit,
value,
@@ -367,35 +389,39 @@ def _ford_pipes(
one_m: float | None,
names: dict[str, str],
pipes: list[dict[str, Any]],
body: str | None,
) -> None:
"""세월교 관 몫 — 한 련 길이 × 련 수를 관매설 · 커플링 열에 더함(관 공과 같은 열 · 131).
`pipes` 에는 련마다 한 줄(좌측 패널 1본 길이로 커플링을 다시 셀 때 련 수만큼 셈).
`pipes` 에는 련마다 한 줄(좌측 패널 1본 길이로 커플링을 다시 셀 때 련 수만큼 셈) ·
조합 = 시설 본체 + 본체 잇기(`body` · 143-1).
"""
diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000)
count = int(_num(base["options"].get("pipe_count")) or 1)
head = ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"]
combos = [_combo(base.get("summary_item"))]
diameter = _count(base["options"], "pipe_diameter_mm")
count = _count(base["options"], "pipe_count")
head = _pipe_head(names, diameter)
combos = [_combo(base.get("summary_item")), body]
each = couplings(length, one_m)
sheet.add(
at,
f"len|{diameter}",
(2, diameter, 0),
f"len|{_key(diameter)}",
(2, diameter or math.inf, 0),
[*head, "관매설"],
"m",
length * count if length else None,
length * count if length and count else None,
combos,
)
sheet.add(
at,
f"cp|{diameter}",
(2, diameter, 1),
f"cp|{_key(diameter)}",
(2, diameter or math.inf, 1),
[*head, "커플링"],
"ea",
None if each is None else each * count,
None if each is None or count is None else each * count,
combos,
)
pipes += [{"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length}] * count
if any(combos):
column = f"pipe:cp|{_key(diameter)}"
pipes += [{"chainage_m": at, "column": column, "length_m": length}] * (count or 0)
def _pavement(
@@ -467,12 +493,15 @@ def pipe_summary(
names: dict[str, str],
one_m: float | None,
kinds: dict[str, Any] | None = None,
linked: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""횡단배수 한 곳마다 컨테이너 값 → 집계표 열 · 줄 · 계(저장 안 함).
`names` = 관 정의 이름(공용 흙막이 · 돌붙임 머리) · `kinds` = M02 정의(비면 읽음).
`names` = 관 정의 이름(공용 흙막이 · 돌붙임 머리) · `kinds` = M02 정의(비면 읽음) ·
`linked` = 연결된 사본(비면 읽음) — 칸마다 그 칸을 낸 컨테이너 사본이 있을 때만 셈(143-1).
"""
kinds = kinds or definition.read()
linked = snapshots.linked_snapshots(project_root) if linked is None else linked
items = fill.collect_items(
project_root,
fill.pipe_lengths_from_designs(designs),
@@ -488,15 +517,19 @@ def pipe_summary(
box_lengths = _station_values(designs, "box_length_m")
ford_lengths = _station_values(designs, "ford_pipe_length_m")
by_owner: dict[tuple[str, str], dict[str, Any]] = {}
others: dict[str, bool] = {} # 집계표 밖 구조물 — 사본 하나라도 있나(조합 열은 화면이 그림)
for item in items:
if item["type_id"] not in DRAIN_TYPES:
continue
owner, _, link = item["복사본"].partition(GROUP_JOIN)
if item["type_id"] not in DRAIN_TYPES:
others[owner] = others.get(owner, False) or item["복사본"] in linked
continue
entry = by_owner.setdefault((item["type_id"], owner), {"links": {}})
# 거름 한 곳 — 사본 없는 잇기는 조합 없음으로(그 칸만 안 셈 · 143-1)
combo = _combo(item.get("summary_item")) if item["복사본"] in linked else None
if link:
entry["links"][link] = _combo(item.get("summary_item"))
entry["links"][link] = combo
else:
entry["base"] = item
entry["base"] = {**item, "summary_item": item.get("summary_item") if combo else None}
sheet = _Sheet()
pipes: list[dict[str, Any]] = []
@@ -506,28 +539,36 @@ def pipe_summary(
if base is None:
continue
links = entry["links"]
sheet.owner = owner
point = raw.get((type_id, owner))
options = dict(point.options or {}) if point else {}
at = float(base["chainage_m"])
if type_id != "pipe":
box_length = fill._nearest(box_lengths, at) if type_id == "box_culvert" else None
no_box += type_id == "box_culvert" and not box_length
group = (point.group_items if point else None) or {}
group = { # 세월교 날 잇기(저장된 잇기)도 사본 있는 것만
gid: chosen
for gid, chosen in ((point.group_items if point else None) or {}).items()
if f"{owner}{GROUP_JOIN}{gid}" in linked
}
_drain(sheet, at, type_id, base, options, links, group, kinds, names, box_length)
body_link = BOX_LINKS["body"] if type_id == "box_culvert" else FORD_LINKS["body"]
body = links.get(body_link) or _combo(group.get(body_link))
counted = bool(base["summary_item"] or body) # 본체 칸을 셈(사본 있음)
no_box += type_id == "box_culvert" and counted and not box_length
if type_id == "ford_bridge":
ford_length = fill._nearest(ford_lengths, at)
no_ford += not ford_length
_ford_pipes(sheet, at, base, ford_length, one_m, names, pipes)
no_ford += counted and not ford_length
_ford_pipes(sheet, at, base, ford_length, one_m, names, pipes, body)
continue
diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000)
pipe_head = ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"]
diameter = _count(base["options"], "pipe_diameter_mm")
pipe_head = _pipe_head(names, diameter)
pipe_combos = [_combo(base.get("summary_item")), links.get(LINKS["pipe"])]
length = base.get("관연장")
no_length += not length
no_length += any(pipe_combos) and not length
sheet.add(
at,
f"len|{diameter}",
(2, diameter, 0),
f"len|{_key(diameter)}",
(2, diameter or math.inf, 0),
[*pipe_head, "관매설"],
"m",
length,
@@ -535,21 +576,23 @@ def pipe_summary(
)
sheet.add(
at,
f"cp|{diameter}",
(2, diameter, 1),
f"cp|{_key(diameter)}",
(2, diameter or math.inf, 1),
[*pipe_head, "커플링"],
"ea",
couplings(length, one_m),
pipe_combos,
)
pipes.append({"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length})
if any(pipe_combos):
column = f"pipe:cp|{_key(diameter)}"
pipes.append({"chainage_m": at, "column": column, "length_m": length})
shape = fill._nearest(basins, at)
no_basin += shape is None
no_basin += bool(links.get(LINKS["inlet"])) and shape is None
if shape:
sheet.add(
at,
f"basin|{diameter}|{shape}",
(2, diameter, 2 + _order(SHAPES, shape), shape),
f"basin|{_key(diameter)}|{shape}",
(2, diameter or math.inf, 2 + _order(SHAPES, shape), shape),
[*pipe_head, "관보호공"],
names.get(f"inlet.form={shape}", shape),
1.0,
@@ -558,11 +601,18 @@ def pipe_summary(
sides = [side for side in ("inlet", "outlet") if options.get(f"{side}_type") == SOIL]
sources = [(side, links.get(LINKS[side])) for side in sides]
_revets(sheet, at, options, links, sources, pipe_wall_min_height_m(diameter / 1000), names)
# 관경 빈칸 = 빈 높이도 「미입력」(관경 최소 높이를 모름)
wall = pipe_wall_min_height_m(diameter / 1000) if diameter else None
_revets(sheet, at, options, links, sources, wall, names)
_chutes(sheet, at, options, links, names)
out = sheet.result()
notes = []
unlinked = len(sheet.unlinked) + sum(not found for found in others.values())
if unlinked:
notes.append(
f"조합 사본 없는 시설 {unlinked}곳 — B05 · B06 좌측 패널에서 조합 연결 · [저장]"
)
if no_length:
notes.append(f"관 연장 없음 {no_length}곳 — B06 횡단 [저장] 뒤 채워짐")
if no_basin:
@@ -579,6 +629,7 @@ def pipe_summary(
"one_length_m": one_m,
"types": list(DRAIN_TYPES),
"pipes": pipes,
"linked": linked,
}
+39 -6
View File
@@ -4,12 +4,14 @@
*
* 줄 = 구조물 · 배관이 놓인 측점(같은 측점은 한 줄 · 오름차순)
* 열 = 저장된 구조물 조합(본체 `summary_item` + 그룹 `group_items`) — 같은 층 · 주인 · 키는 한 열
* 조합 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · 서버 `linked_snapshots` · PLAN 143-1)
*
* 읽기만 — B05/B06 저장 자료(structures.json · pipe_points.json)를 고치지 않는다.
* 런타임 import 는 `common_util_summary_item` 하나 — 시험이 두 파일을 이어 붙여 돌림.
* ========================================================================== */
import {
GROUP_JOIN,
readGroupItems,
readSummaryItem,
type SummaryItem,
@@ -25,6 +27,35 @@ export interface PlacedItem {
group_items?: unknown;
}
/** 연결된 사본 한 건(서버 `M02_Item_Snapshot.linked_snapshots` · 143-1) — `경로` = 가져온 때 M02 경로. */
export interface LinkedSnapshot {
항목: SummaryItem;
폴더: string;
/** 있는 부분(도면 · 면적 · 일위대가) */
부분: string[];
경로: string[];
}
/** 사본 있는 조합만 남김 — 본체 = `{구조물 id}` · 그룹 = `{구조물 id}__{그룹}` 사본 · 하나도 없으면 뺌. */
export function linkedOnly<T extends PlacedItem>(
things: readonly T[],
linked: Readonly<Record<string, LinkedSnapshot>>,
): T[] {
const out: T[] = [];
for (const thing of things) {
const id = thing.structure_id ?? "";
const own = id in linked ? readSummaryItem(thing.summary_item) : null;
const groups = Object.fromEntries(
Object.entries(readGroupItems(thing.group_items)).filter(
([gid]) => `${id}${GROUP_JOIN}${gid}` in linked,
),
);
if (own || Object.keys(groups).length)
out.push({ ...thing, summary_item: own, group_items: groups });
}
return out;
}
/** M02 조합 정보에서 읽은 구분 · 이름 — 못 찾으면 null(저장된 이름으로 보임). */
export interface ComboLabel {
group: string;
@@ -84,11 +115,12 @@ export function summaryColumns(
);
}
/** 한 측점에 놓인 조합 한 건 — 어느 구조물 것인지(복사본 찾기용)까지. */
/** 한 측점에 놓인 조합 한 건 — 어느 구조물 것인지 · 사본 id(본체 = 구조물 id · 그룹 = `{id}__{그룹}`). */
export interface StationCombo {
id: string;
item: SummaryItem;
structureId: string | null;
copyId: string | null;
}
/** 측점(1cm 안 같은 측점)에 놓인 조합들 — 구조물마다 본체 → 그룹 차례 · 같은 구조물의 같은 조합은 한 건. */
@@ -99,16 +131,17 @@ export function combosAt(things: readonly PlacedItem[], station: number): Statio
const at = stationOf(thing);
if (at === null || Math.round(at * 100) !== Math.round(station * 100)) continue;
const own = readSummaryItem(thing.summary_item);
for (const item of [
...(own ? [own] : []),
...Object.values(readGroupItems(thing.group_items)),
const structureId = thing.structure_id ?? null;
for (const [gid, item] of [
...(own ? [["", own] as const] : []),
...Object.entries(readGroupItems(thing.group_items)),
]) {
const structureId = thing.structure_id ?? null;
const key = `${structureId}
${comboId(item)}`;
const copyId = structureId && (gid ? `${structureId}${GROUP_JOIN}${gid}` : structureId);
if (!seen.has(key)) {
seen.add(key);
found.push({ id: comboId(item), item, structureId });
found.push({ id: comboId(item), item, structureId, copyId });
}
}
}
+24 -64
View File
@@ -4,7 +4,7 @@
* 조합 한 개 = M02 항목 카드(`M02_MasterTemplete_ItemCards`) 그대로 — 도면(도각 포함 · 보기 전용 CAD ·
* 여러 장이면 장 탭) + 면적산출.
*
* 원천 = 그 구조물의 프로젝트 복사본(같은 항목일 때) · 없으면 M02 원본 층 — 읽기만
* 원천 = 그 구조물의 프로젝트 사본만(연결된 사본 · PLAN 143-1) — 없으면 「사본 없음」 · 읽기만
* 조합 목록 = 집계표와 같음(`B08_Quantity_Summary_Data` `combosAt`)
* 카드 · CAD · 스프레드시트는 모달을 열 때만 `import()` · 닫을 때 카드를 모두 정리
* ========================================================================== */
@@ -12,19 +12,11 @@
import "./B08_Quantity_UI_Drawing.css";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import { layerOf, type PickerRow } from "@ui/ui_template_item_picker_data";
import {
PART_OF,
projectItems,
sameItem,
structureFolder,
type ProjectItemInfo,
} from "@ui/ui_template_item_picker_project";
import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { PART_OF, projectItems } from "@ui/ui_template_item_picker_project";
import type { ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards";
import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource";
import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item";
import type { ComboLabel, StationCombo } from "./B08_Quantity_Summary_Data";
import type { ComboLabel, LinkedSnapshot, StationCombo } from "./B08_Quantity_Summary_Data";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -42,48 +34,21 @@ export interface SheetsRequest {
/** 모달 제목에 붙는 측점 글 */
station: string;
combos: StationCombo[];
/** `comboId` → M02 줄 */
rows: Map<string, PickerRow>;
/** 연결된 사본 `{구조물 id: 사본}`(집계 응답 · 143-1) */
linked: Record<string, LinkedSnapshot>;
describe: (item: SummaryItem) => ComboLabel | null;
}
/** 그 구조물의 복사본 목록 — 프로젝트 복사본을 못 읽으면 원본만(모달은 그대로 열림). */
const loadCopies = (projectId: string): Promise<ProjectItemInfo[]> =>
projectItems(projectId)
.list()
.catch(() => []);
function sourceOf(
request: SheetsRequest,
combo: StationCombo,
row: PickerRow,
copies: ProjectItemInfo[],
): { source: ItemSource; copy: boolean } {
const own = combo.structureId
? copies.find((info) => info.폴더 === structureFolder(combo.structureId!))
: undefined;
if (own && sameItem(own, row)) {
const items = projectItems(request.projectId);
return {
copy: true,
source: {
layer: "project",
projectId: request.projectId,
read: async (part) => {
const doc = await items.read(own.폴더, PART_OF[part]);
if (doc == null) throw new Error(L("B08_Quantity_Sheets_NoCopy"));
return { 문서: doc };
},
},
};
}
const layer = layerOf(row);
/** 사본 폴더 읽기 — 그 부분 파일이 없으면 던짐(카드가 「없음」). */
function sourceOf(projectId: string, folder: string): ItemSource {
const items = projectItems(projectId);
return {
copy: false,
source: {
layer,
projectId: null,
read: (part, column) => readTemplate(layer, part, column, null),
layer: "project",
projectId,
read: async (part) => {
const doc = await items.read(folder, PART_OF[part]);
if (doc == null) throw new Error(L("B08_Quantity_Sheets_NoCopy"));
return { 문서: doc };
},
};
}
@@ -92,7 +57,6 @@ function sourceOf(
function comboSection(
request: SheetsRequest,
combo: StationCombo,
copies: ProjectItemInfo[],
createItemCards: typeof import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards").createItemCards,
handles: ItemCardsHandle[],
): HTMLElement {
@@ -100,7 +64,7 @@ function comboSection(
const name = label
? [label.group, label.name].filter(Boolean).join(" ")
: combo.item.name || combo.item.key;
const row = request.rows.get(combo.id) ?? null;
const copy = combo.copyId ? request.linked[combo.copyId] : undefined;
const section = document.createElement("section");
section.className = "b08-sheets__combo";
section.dataset.b08Combo = combo.id;
@@ -109,27 +73,25 @@ function comboSection(
title.textContent = name;
title.title = summaryTooltip(combo.item);
section.append(title);
if (!row?.열) {
if (!copy) {
section.append(note(L("B08_Quantity_Drawing_Missing")));
return section;
}
const picked = sourceOf(request, combo, row, copies);
const source = sourceOf(request.projectId, copy.폴더);
const from = document.createElement("span");
from.className = "b08-sheets__from";
from.textContent = L(
picked.copy ? "B08_Quantity_Sheets_FromCopy" : "B08_Quantity_Sheets_FromMaster",
);
from.textContent = L("B08_Quantity_Sheets_FromCopy");
title.append(from);
if (!row.도번) section.append(note(L("B08_Quantity_Drawing_NoDrawing")));
if (!copy.부분.includes("도면")) section.append(note(L("B08_Quantity_Drawing_NoDrawing")));
const cards = createItemCards({
source: () => picked.source,
source: () => source,
editable: () => false,
onEdit: () => undefined,
warnOnFail: () => false, // 못 읽으면 카드에 「없음」
});
handles.push(cards);
section.append(cards.root);
void cards.load({ id: row.열, label: name });
void cards.load({ id: copy.폴더, label: name });
return section;
}
@@ -151,14 +113,12 @@ export function openStructureSheets(request: SheetsRequest): void {
body.append(note(L("B08_Quantity_Drawing_Loading")));
void (async () => {
try {
const [{ createItemCards }, copies] = await Promise.all([
import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"),
loadCopies(request.projectId),
]);
const { createItemCards } =
await import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards");
if (closed) return;
body.replaceChildren(
...request.combos.map((combo) =>
comboSection(request, combo, copies, createItemCards, handles),
comboSection(request, combo, createItemCards, handles),
),
);
} catch (error) {
@@ -28,7 +28,7 @@ const SUMMARY_COLUMNS: DataColumn[] = [
export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void {
const info = note(
"토적표 합계 · 평균운반거리 · 종방향 무대운반거리를 실무 토공집계표 식대로 묶음 · " +
"연암 · 경암 = 암 × 좌측 패널 비율 · 구조물 쪽 줄은 구조물 수량에서(- = 아직 값 없음)",
"연암 · 경암 = 암 × 좌측 패널 비율 · 「비율 미정」 = 반영 비율 확인 뒤 채움(- = 아직 값 없음)",
);
const grid = createDataTable({
columns: SUMMARY_COLUMNS,
+5 -1
View File
@@ -430,7 +430,11 @@ export function buildHydroSheet(
dims.append(
rowF("내공 폭 B (m)", null, numberInput("box_width_m", "입력")),
rowF("내공 높이 H (m)", null, numberInput("box_height_m", "입력")),
rowF("수심비 h / H", hint(`기본 ${sheetDefaults.depth_ratio}`), numberInput("depth_ratio")),
rowF(
"수심비 h / H",
hint("빈칸이면 계산 멈춤 — 값 넣기"),
numberInput("depth_ratio", "입력"),
),
rowF("수심 h (m)", formula("h = 수심비 × H"), outCell("depth")),
rowF("단면적", formula("A = B × h"), outCell("section_area")),
rowF("윤변", formula("P = B + 2h"), outCell("perimeter")),
+34 -18
View File
@@ -7,11 +7,13 @@
* 고칠 칸 = 단위수량 · ㎥당 배합 · 할증 % — 조작 = 캐시(`b08-materials`) + 짝 계산(`Material_Calc.ts`)으로 바로
* 총괄 모래 · 자갈 · 시멘트 할증 = 좌측 패널 값(PLAN 124 · 표에서 안 고침 · 패널 조작에 바로 다시 셈)
* [저장] = PUT …/quantity/materials → 서버가 정본으로 다시 셈 · 캐시 비움 · 자동 저장 없음
* 값 없는 줄은 숨김(화면만 · 셈 · 열 · 단위수량은 그대로) · 표는 공용 카드 안(PLAN 143-3)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import {
createButton,
createCard,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
@@ -49,6 +51,12 @@ const HINTS: Record<MaterialSheetId, string> = {
concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음",
quantity: "수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1",
};
const HIDDEN = "값 없는 줄은 숨김";
/** 보일 줄 차례(원래 줄 번호) — 값 없는 줄은 숨김(143-3) */
const shown = <T>(rows: readonly T[], has: (row: T) => boolean): number[] =>
rows.flatMap((row, i) => (has(row) ? [i] : []));
const anyValue = (cells: Cells): boolean => Object.values(cells).some((v) => v != null);
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
@@ -99,7 +107,9 @@ interface Table {
function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table {
const { materials, rows, total } = s.quantity;
const rowsOut: DataRow[] = rows.flatMap((row) => {
const keep = shown(rows, (row) => row.qty != null || anyValue(row.units));
const rowsOut: DataRow[] = keep.flatMap((at) => {
const row = rows[at];
const unit: DataRow = {
name: row.name,
spec: row.spec,
@@ -137,7 +147,7 @@ function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table {
total: totalRow,
frozen: 5,
editable: (i, key) => i % 2 === 0 && key.startsWith("m:"),
target: (i, key) => [o.units, rows[i / 2].id, key.slice(2)],
target: (i, key) => [o.units, rows[keep[i / 2]].id, key.slice(2)],
};
}
@@ -149,9 +159,11 @@ const MIX_LABEL: Record<(typeof MIX_FIELDS)[number], [string, string, string]> =
function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
const { rows, total } = s.concrete;
const rowsOut: DataRow[] = rows.map((row, i) => {
const keep = shown(rows, (row) => row.qty != null || anyValue(row.amounts));
const rowsOut: DataRow[] = keep.map((at, i) => {
const row = rows[at];
const out: DataRow = {
name: i > 0 && rows[i - 1].name === row.name ? "" : row.name,
name: i > 0 && rows[keep[i - 1]].name === row.name ? "" : row.name,
spec: row.spec,
qty: row.qty,
};
@@ -187,12 +199,13 @@ function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
total: totalRow,
frozen: 2,
editable: (_i, key) => key.startsWith("per:"),
target: (i, key) => [o.mix, rows[i].key, key.slice(4)],
target: (i, key) => [o.mix, rows[keep[i]].key, key.slice(4)],
};
}
function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
const { rows } = s.material;
const keep = shown(rows, (row) => row.qty != null || row.amount != null);
return {
columns: [
{ key: "group", label: "구분", width: 96 },
@@ -203,19 +216,22 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
numberColumn({ key: "amount", label: "합계", width: 96 }),
{ key: "note", label: "비고", width: 96 },
],
rows: rows.map((row, i) => ({
group: i > 0 && rows[i - 1].group === row.group ? "" : row.group,
spec: row.spec,
unit: row.unit,
qty: row.qty,
surcharge: row.surcharge,
amount: row.amount,
note: row.panel ? "할증 = 좌측 패널" : "",
})),
rows: keep.map((at, i) => {
const row = rows[at];
return {
group: i > 0 && rows[keep[i - 1]].group === row.group ? "" : row.group,
spec: row.spec,
unit: row.unit,
qty: row.qty,
surcharge: row.surcharge,
amount: row.amount,
note: row.panel ? "할증 = 좌측 패널" : "",
};
}),
total: null,
frozen: 2,
editable: (i, key) => key === "surcharge" && !rows[i].panel,
target: (i) => [o.surcharge, rows[i].key, null],
editable: (i, key) => key === "surcharge" && !rows[keep[i]].panel,
target: (i) => [o.surcharge, rows[keep[i]].key, null],
};
}
@@ -254,7 +270,7 @@ export async function renderMaterialSheet(
const toolbar = document.createElement("div");
toolbar.className = "b08-material__toolbar";
const notes = document.createElement("div");
notes.append(note(BASIS), note(HINTS[sheet]));
notes.append(note(BASIS), note(`${HINTS[sheet]} · ${HIDDEN}`));
if (sheet !== "quantity") {
// 빈 까닭 한 줄(131) — 단위수량 빈 줄 · 서버 알림 · 빈 칸은 빈칸 그대로
const blank = blankUnitRows(sheets);
@@ -300,7 +316,7 @@ export async function renderMaterialSheet(
built = BUILD[sheet](sheets, overrides);
table.setRows(built.rows, built.total);
};
host.replaceChildren(toolbar, table.root);
host.replaceChildren(createCard({ body: [toolbar, table.root] }));
onOptionsChange(host, () => {
sheets = calc();
redraw();
+2 -1
View File
@@ -142,8 +142,9 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void
rockRow.className = "b08-options__row";
rockRow.append(soft.root, hard.root);
// 1본 = 프로젝트 값만(읽기 전 빈칸 · 143-2)
let saved: { one: number | null; soft: number; surcharge: Cells } = {
one: 8,
one: null,
soft: 90,
surcharge: PANEL_SURCHARGE,
};
+29 -27
View File
@@ -4,7 +4,8 @@
* 표는 공용 많은 자료 표(`ui_template_data_table` · PLAN 70-2) — 머리 · 측점 열 고정.
*
* 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만)
* 머리 두 줄 = 조합 구분(M02 경로 첫 칸) · 조합 이름(나머지 경로) — `GET /api/m02/items?all=1`
* 머리 두 줄 = 조합 구분(경로 첫 칸) · 조합 이름(나머지 경로) — 사본 manifest 경로(집계 응답 `linked`)
* 조합 · 측점 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · PLAN 143-1) — 없으면 그 시설은 없음
* 횡단배수 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98 · 120) — 머리 구조물 · 형식 · 규격
* 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 서버가 펼친 종류(`types`)는
* 조합 열에서 뺌 · 다른 구조물 열은 조합 그대로
@@ -15,9 +16,7 @@
import { API_BASE_URL } from "@config/config_frontend";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { loadRows as loadPickerRows } from "@ui/ui_template_item_picker";
import { pathOf, rowId, type PickerRow } from "@ui/ui_template_item_picker_data";
import { showToast } from "@ui/ui_template_elements";
import { createCard, showToast } from "@ui/ui_template_elements";
import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures";
import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
@@ -28,10 +27,12 @@ import { summaryTooltip, type SummaryItem } from "../common_util/common_util_sum
import {
combosAt,
comboId,
linkedOnly,
summaryColumns,
summaryStations,
type ComboColumn,
type ComboLabel,
type LinkedSnapshot,
type PlacedItem,
type StationCombo,
widthOf,
@@ -72,6 +73,8 @@ interface PipeSummary {
types: string[];
one_length_m: number | null;
pipes: PipeLength[];
/** 연결된 사본 `{구조물 id: 사본}`(143-1) */
linked: Record<string, LinkedSnapshot>;
}
async function fetchPipeSummary(projectId: string): Promise<PipeSummary> {
@@ -88,7 +91,7 @@ const stationKey = (station: number): number => Math.round(station * 100);
const placesOf = (values: number[]): number =>
Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length));
/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(M02 에 있으면 · 조합 잇기 98). */
/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(사본 경로 · 조합 잇기 98). */
function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] {
const top =
summary.columns
@@ -168,12 +171,12 @@ async function openSheets(
projectId: string,
station: string,
combos: StationCombo[],
rows: Map<string, PickerRow>,
linked: Record<string, LinkedSnapshot>,
describe: (item: SummaryItem) => ComboLabel | null,
): Promise<void> {
try {
const { openStructureSheets } = await import("./B08_Quantity_UI_Drawing");
openStructureSheets({ projectId, station, combos, rows, describe });
openStructureSheets({ projectId, station, combos, linked, describe });
} catch (error) {
showToast(`${L("B08_Quantity_Drawing_Failed")} — ${String(error)}`, "error");
}
@@ -183,9 +186,8 @@ async function openSheets(
export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise<void> {
host.replaceChildren(note(L("B08_Quantity_Summary_Loading")));
try {
const [structures, pickerRows, pipes, interval, pipe] = await Promise.all([
const [structures, pipes, interval, pipe] = await Promise.all([
fetchStructures(projectId),
loadPickerRows(),
// 배수 분석 전이면 관이 없음 — 구조물만으로 그림
fetchDetailPipePoints(projectId).then(
(response) => response.pipe_points,
@@ -195,28 +197,28 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
// 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로
fetchPipeSummary(projectId).catch((error: unknown) => String(error)),
]);
const m02 = new Map(pickerRows.map((row) => [rowId(row), row]));
const saved = typeof pipe === "string" ? null : pipe;
// 조합 이름 = 사본 manifest 경로(층 목록을 안 읽음 · 143-1)
const linked = saved?.linked ?? {};
const paths = new Map(
Object.values(linked).map((copy) => [comboId(copy.항목), copy.경로] as const),
);
const describe = (item: SummaryItem): ComboLabel | null => {
const row = m02.get(comboId(item));
if (!row) return null;
const [group = "", ...rest] = pathOf(row);
return { group, name: rest.join(" ") || group || row.key };
const [group = "", ...rest] = paths.get(comboId(item)) ?? [];
return group ? { group, name: rest.join(" ") || group } : null;
};
const comboPath = (id: string): string | null => {
const row = m02.get(id);
return row ? pathOf(row).join(" ") : null;
};
const placed = pipes.map((point) => ({
...point,
structure_id: pipeStructureId(point.chainage_m),
}));
const things: PlacedItem[] = [...structures.structures, ...placed];
const comboPath = (id: string): string | null => paths.get(id)?.join(" ") || null;
const placed = linkedOnly(
pipes.map((point) => ({ ...point, structure_id: pipeStructureId(point.chainage_m) })),
linked,
);
const mine = linkedOnly(structures.structures, linked);
const things: PlacedItem[] = [...mine, ...placed];
const stations = summaryStations(things);
// 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부)
const saved = typeof pipe === "string" ? null : pipe;
const served = new Set(saved?.types ?? []);
const others = [
...structures.structures.filter((item) => !served.has(item.type_id)),
...mine.filter((item) => !served.has(item.type_id)),
...placed.filter((point) => !served.has(point.facility ?? "pipe")),
];
const columns = summaryColumns(others, describe);
@@ -231,12 +233,12 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
const draw = (): HTMLElement =>
stations.length
? buildTable(stations, columns, live(), comboPath, interval, (station, label) =>
openSheets(projectId, label, combosAt(things, station), m02, describe),
openSheets(projectId, label, combosAt(things, station), linked, describe),
)
: note(L("B08_Quantity_Summary_NoStation"));
let table = draw();
parts.push(table);
host.replaceChildren(...parts);
host.replaceChildren(createCard({ body: parts })); // 공용 카드 안(토공 표와 같은 꼴 · 143-3)
onOptionsChange(host, () => {
const next = draw();
table.replaceWith(next);
+28
View File
@@ -304,6 +304,34 @@ def complete_old(folder: Path) -> bool:
return True
#: 「연결된 사본」 부분 이름 → 파일(있는 것만 `부분` 에 담음)
LINKED_PARTS = (("도면", "도면.json"), ("면적", "면적.json"), ("일위대가", LABOR_FILE))
def linked_snapshots(project_root: str | Path) -> dict[str, dict[str, Any]]:
"""연결된 사본 — 지정 ∩ 같은 항목 사본 `{구조물 id: {항목, 폴더, 부분, 경로}}`(PLAN 143-1).
구조물 id = `assignments` 키(관 지점 `pipe@측점` · 옵션 그룹 `{id}__{그룹}`) ·
미지정 · 사본 없음 · 다른 항목 사본은 안 담음(M02 `assign_items` 규칙) ·
원본 층과 대조 안 함(빠른 읽기 · 쓰기 없음) ·
`경로` = manifest 원본 경로(가져온 때 M02 구분 · 이름) · B08 · B09 공용.
"""
base = structures_dir(project_root)
found: dict[str, dict[str, Any]] = {}
for owner, item in assignments(project_root).items():
folder = base / folder_name(owner)
manifest = read_json(folder / MANIFEST_NAME)
if not same_item(manifest, item):
continue
found[owner] = {
"항목": item,
"폴더": folder.name,
"부분": [part for part, name in LINKED_PARTS if (folder / name).is_file()],
"경로": list(manifest["원본"].get("경로") or []),
}
return found
def read_snapshot(project_root: str | Path, owner_id: str) -> dict[str, Any] | None:
"""그 구조물 복사본의 `집계.json`(`{줄, 열}`) — 없으면 None(옛 항목 폴더는 먼저 옮겨 받음)."""
if items_dir(project_root).is_dir():
+27 -5
View File
@@ -8,6 +8,8 @@
400 세월교 Φ1000×2련 월류 폭 12 · 유입 날개벽 · 유출 흙막이 찰(높이 빈칸 → 미입력) 3 + 4
500 물넘이 포장 월류 폭 8 · H=1.0
600 개거 + 노출형 횡단수로 · 700 맹암거(그룹 조합)
143-1 — 칸은 그 칸을 낸 조합 사본이 있을 때만 셈: 견본은 칸을 내는 잇기를 모두 두고 연결된 사본 =
지정 전부 · 사본 없으면 없음은 test_143_1
"""
from __future__ import annotations
@@ -22,6 +24,7 @@ import pytest
from B06_Section.B06_Section_Schema import CrossSectionPatch
from B08_Quantity import B08_Quantity_Router_Summary as summary
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
ROOT = Path(__file__).resolve().parents[2]
@@ -36,7 +39,7 @@ def _item(key: str) -> dict:
POINTS = [
{"chainage_m": 300.0, "options": {"pipe_diameter_mm": 800}},
{"chainage_m": 300.0, "options": {"pipe_diameter_mm": 800}, "summary_item": _item("SS000100")},
{
"chainage_m": 300.0,
"facility": "box_culvert",
@@ -50,7 +53,11 @@ POINTS = [
"outlet_revet_after_m": 5,
},
"summary_item": _item("SS000101"),
"group_items": {"gboxbody": _item("SS000102")},
"group_items": {
"gboxbody": _item("SS000102"),
"gboxin": _item("SS000106"),
"gboxout": _item("SS000107"),
},
},
{
"chainage_m": 340.0,
@@ -61,6 +68,8 @@ POINTS = [
"wing_in": "없음",
"wing_out": "날개벽",
},
"summary_item": _item("SS000101"),
"group_items": {"gboxout": _item("SS000107")},
},
{
"chainage_m": 400.0,
@@ -77,21 +86,33 @@ POINTS = [
"outlet_revet_before_m": 3,
"outlet_revet_after_m": 4,
},
"group_items": {"gford": _item("SS000103"), "gfordin": _item("SS000104")},
"group_items": {
"gford": _item("SS000103"),
"gfordin": _item("SS000104"),
"gfordout": _item("SS000108"),
},
},
{
"chainage_m": 500.0,
"facility": "ford_pavement",
"options": {"ford_width_m": 8, "ford_height_m": 1.0},
"summary_item": _item("SS000109"),
},
]
STRUCTURES = [
{"structure_id": "s-ditch", "type_id": "open_ditch", "placement": "point", "chainage_m": 600},
{
"structure_id": "s-ditch",
"type_id": "open_ditch",
"placement": "point",
"chainage_m": 600,
"summary_item": _item("SS000110"),
},
{
"structure_id": "s-exposed",
"type_id": "cross_drain_exposed",
"placement": "point",
"chainage_m": 600,
"summary_item": _item("SS000111"),
},
{
"structure_id": "s-ud",
@@ -123,7 +144,8 @@ def out(tmp_path: Path) -> dict:
encoding="utf-8",
)
names = definition.read()["종류"]["pipe"]["이름"]
return summary.pipe_summary(root, DESIGNS, names, 6.0)
linked = {owner: {} for owner in snapshots.assignments(root)} # 지정 전부 사본 있음
return summary.pipe_summary(root, DESIGNS, names, 6.0, linked=linked)
def _cell(out: dict, at: float, cid: str):
+5 -4
View File
@@ -40,9 +40,9 @@ def root(tmp_path, monkeypatch) -> Path:
return project
def test_기본값은_8_m_와_90_10_파일에_안_씀(root):
def test_1본_프로젝트_값_없으면_빈칸_90_10_파일에_안_씀(root):
assert options.read_options(root) == {
"one_pipe_length_m": 8,
"one_pipe_length_m": None, # 143-2 — 시스템 층 8 m 로 안 채움
"rock_ratios_pct": {"연암": 90.0, "경암": 10.0},
"material_surcharge_pct": {"sand": 12, "gravel": 5, "cement": 3}, # 124
}
@@ -51,7 +51,7 @@ def test_기본값은_8_m_와_90_10_파일에_안_씀(root):
def test_1본_6_m_저장_뒤_커플링을_다시_셈(root):
assert summary.couplings(22, summary.one_pipe_length(root)) == 2 # 올림(22/8) − 1
assert summary.couplings(22, summary.one_pipe_length(root)) is None # 프로젝트 값 없음
out = options.save_options(root, options.OptionsSave(one_pipe_length_m=6))
assert out["one_pipe_length_m"] == 6 == summary.one_pipe_length(root)
assert summary.couplings(22, 6) == 3 and summary.couplings(16, 6) == 2
@@ -63,6 +63,7 @@ def test_1본_6_m_저장_뒤_커플링을_다시_셈(root):
# 다시 저장 = 같은 사본 판 위에 씀
options.save_options(root, options.OptionsSave(one_pipe_length_m=8))
assert summary.one_pipe_length(root) == 8
assert summary.couplings(22, summary.one_pipe_length(root)) == 2 # 올림(22/8) − 1
def test_비율_70_30_저장_뒤_남고_다른_칸은_그대로(root):
@@ -79,7 +80,7 @@ def test_비율_70_30_저장_뒤_남고_다른_칸은_그대로(root):
assert stored["rock_methods"] == {"연암": "ripping"}
assert options.read_options(root)["rock_ratios_pct"]["연암"] == 70.0
# 1본 길이는 안 보냈으니 그대로
assert options.read_options(root)["one_pipe_length_m"] == 8
assert options.read_options(root)["one_pipe_length_m"] is None
def test_비율_읽기_자름_경암만_있을_때():
@@ -20,6 +20,7 @@ from test_121_b08_material_sheets import SUMMARY, UNITS, _run_ts
from B08_Quantity import B08_Quantity_Router_Material as router_mod
from B08_Quantity import B08_Quantity_Router_Summary as summary_mod
from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute, finish
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
ROOT = Path(__file__).resolve().parents[2]
@@ -107,7 +108,8 @@ def _ford_summary(tmp_path: Path, designs: list[dict]) -> dict:
encoding="utf-8",
)
names = definition.read()["종류"]["pipe"]["이름"]
return summary_mod.pipe_summary(root, designs, names, 6.0)
linked = {owner: {} for owner in snapshots.assignments(root)} # 143-1 지정 전부 사본 있음
return summary_mod.pipe_summary(root, designs, names, 6.0, linked=linked)
def _cell(out: dict, at: float, cid: str):
@@ -0,0 +1,199 @@
# -*- coding: utf-8 -*-
"""B08 구조물 집계표 — 조합 사본 없으면 없음(PLAN 143-1).
견본(관 지점 넷 + 세월교 둘 + 개거 하나):
100 관 Φ800 L16 · 사본 있음 · 유출 흙막이 잇기 사본 없음 → 관 칸만 · 유출 흙막이 칸 없음
200 관 Φ800 L18 · 지정 있으나 사본 폴더가 다른 항목 → 없음
300 관 Φ800 L20 · 지정 있으나 사본 폴더 없음 → 없음
400 관 Φ800 L22 · 지정 없음 → 없음
500 세월교 Φ1000×2련 · 본체 잇기(gford) 사본 있음 → 관 몫 · 월류 폭 셈
600 세월교 · 잇기 없음 → 관 몫 · pipes 에도 없음
700 개거 · 지정 없음 → 「사본 없는 시설」 에 셈
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from B08_Quantity import B08_Quantity_Router_Summary as summary
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
ROOT = Path(__file__).resolve().parents[2]
def _item(key: str) -> dict:
return {"layer": "system", "owner": None, "key": key, "name": key}
def _soil() -> dict:
return {
"outlet_type": "기슭막이",
"outlet_revet_form": "돌쌓기(메)",
"outlet_revet_height_m": 2,
"outlet_revet_before_m": 5,
"outlet_revet_after_m": 5,
}
POINTS = [
{
"chainage_m": 100.0,
"options": {"pipe_diameter_mm": 800, **_soil()},
"summary_item": _item("SS000001"),
"group_items": {"g9lq9uk": _item("SS000002")},
},
{"chainage_m": 200.0, "options": {"pipe_diameter_mm": 800}, "summary_item": _item("SS000001")},
{"chainage_m": 300.0, "options": {"pipe_diameter_mm": 800}, "summary_item": _item("SS000001")},
{"chainage_m": 400.0, "options": {"pipe_diameter_mm": 800}},
{
"chainage_m": 500.0,
"facility": "ford_bridge",
"options": {"pipe_diameter_mm": 1000, "pipe_count": 2, "ford_width_m": 12},
"group_items": {"gford": _item("SS000003")},
},
{
"chainage_m": 600.0,
"facility": "ford_bridge",
"options": {"pipe_diameter_mm": 1000, "pipe_count": 1, "ford_width_m": 8},
},
]
STRUCTURES = [
{"structure_id": "s-ditch", "type_id": "open_ditch", "placement": "point", "chainage_m": 700},
]
DESIGNS = [
{"chainage_m": 100.0, "design": {"pipe_length_m": 16}},
{"chainage_m": 200.0, "design": {"pipe_length_m": 18}},
{"chainage_m": 300.0, "design": {"pipe_length_m": 20}},
{"chainage_m": 400.0, "design": {"pipe_length_m": 22}},
{"chainage_m": 500.0, "design": {"ford_pipe_length_m": 9}},
{"chainage_m": 600.0, "design": {"ford_pipe_length_m": 7}},
]
def _snapshot(root: Path, owner: str, item: dict, parts: tuple[str, ...] = ()) -> None:
folder = snapshots.structures_dir(root) / snapshots.folder_name(owner)
folder.mkdir(parents=True)
origin = {**item, "경로": ["횡단배수", item["key"], "이름"]}
(folder / snapshots.MANIFEST_NAME).write_text(
json.dumps({"원본": origin}, ensure_ascii=False), encoding="utf-8"
)
for name in parts:
(folder / name).write_text("{}", encoding="utf-8")
@pytest.fixture
def root(tmp_path: Path) -> Path:
found = tmp_path / "proj"
edits = found / "B04_PreProcess/drainage/edits"
edits.mkdir(parents=True)
points = [{**p, "source": "user"} for p in POINTS]
(edits / "pipe_points.json").write_text(
json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8"
)
route = found / "B05_Profile/route"
route.mkdir(parents=True)
(route / "structures.json").write_text(
json.dumps({"revision": 1, "structures": STRUCTURES}, ensure_ascii=False),
encoding="utf-8",
)
_snapshot(found, "pipe@100.0", _item("SS000001"), ("도면.json", "일위대가.json"))
_snapshot(found, "pipe@200.0", _item("SS000009")) # 다른 항목 사본
_snapshot(found, "pipe@500.0__gford", _item("SS000003"))
return found
def _run(root: Path) -> dict:
names = definition.read()["종류"]["pipe"]["이름"]
return summary.pipe_summary(root, DESIGNS, names, 6.0)
def _cell(out: dict, at: float, cid: str):
row = next((r for r in out["rows"] if abs(r["chainage_m"] - at) < 0.01), None)
return None if row is None else row["values"].get(cid)
def test_연결된_사본은_지정_교집합_같은_항목(root):
linked = snapshots.linked_snapshots(root)
assert set(linked) == {"pipe@100.0", "pipe@500.0__gford"}
one = linked["pipe@100.0"]
assert one["항목"]["key"] == "SS000001" and one["폴더"] == "pipe@100.0"
assert one["부분"] == ["도면", "일위대가"] # 있는 파일만
assert one["경로"] == ["횡단배수", "SS000001", "이름"]
def test_사본_없는_칸은_안_셈(root):
out = _run(root)
assert _cell(out, 100, "pipe:len|800") == 16
assert _cell(out, 100, "pipe:cp|800") == 2
# 유출 흙막이 잇기는 사본 없음 → 그 칸만 없음(열도 안 섬)
assert not any(c["id"].startswith("pipe:revet") for c in out["columns"])
# 다른 항목 사본 · 사본 없음 · 지정 없음 → 줄 없음
assert [round(r["chainage_m"]) for r in out["rows"]] == [100, 500]
assert out["total"]["pipe:len|800"] == 16
def test_세월교_관_몫은_본체_잇기_사본으로(root):
out = _run(root)
assert _cell(out, 500, "pipe:len|1000") == 18 # 9 × 2련
assert _cell(out, 500, "pipe:ford|1000|2|width") == 12
assert _cell(out, 600, "pipe:len|1000") is None
assert [(round(p["chainage_m"]), p["column"]) for p in out["pipes"]] == [
(100, "pipe:cp|800"),
(500, "pipe:cp|1000"),
(500, "pipe:cp|1000"),
]
width = next(c for c in out["columns"] if c["id"] == "pipe:ford|1000|2|width")
assert width["combos"] == ["system||SS000003"]
def test_사본_없는_시설_알림_응답에_사본(root):
out = _run(root)
# 100(유출 흙막이) · 200 · 300 · 400 · 500(기본 날개벽 잇기) · 600 · 개거 700 = 7곳
assert not any("wing" in c["id"] for c in out["columns"])
assert "조합 사본 없는 시설 7곳 — B05 · B06 좌측 패널에서 조합 연결 · [저장]" in out["notes"]
assert not any("관 연장 없음" in n for n in out["notes"])
assert set(out["linked"]) == {"pipe@100.0", "pipe@500.0__gford"}
def test_화면_조합_이름_구조물도는_사본에서():
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
assert "loadPickerRows" not in ui # 층 목록 GET 안 씀
assert "linkedOnly(" in ui
drawing = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8")
assert "readTemplate" not in drawing # 층 폴백 없음
assert "B08_Quantity_Sheets_NoCopy" in drawing
def test_화면_사본_있는_조합만_사본_id():
"""`linkedOnly` — 본체 · 그룹마다 사본 있는 것만 · 하나도 없으면 시설째 뺌 · `combosAt` 사본 id."""
import re
import subprocess
imports = re.compile(r"^import[^;]*;\n", re.MULTILINE)
sources = [
ROOT / "common_util" / "common_util_summary_item.ts",
ROOT / "B08_Quantity" / "B08_Quantity_Summary_Data.ts",
]
things = [
{"structure_id": "s1", "chainage_m": 40, "summary_item": _item("SS1"),
"group_items": {"g1": _item("SS3"), "g2": _item("SS4")}},
{"structure_id": "s2", "chainage_m": 80, "summary_item": _item("SS2")},
] # fmt: skip
linked = {"s1__g1": {}, "s1": {}}
body = (
f"\nconst kept = linkedOnly({json.dumps(things)}, {json.dumps(linked)});"
"\nconsole.log(JSON.stringify([kept.map((t) => [t.structure_id, Object.keys(t.group_items)]),"
" combosAt(kept, 40).map((c) => c.copyId)]));"
)
script = "\n".join(imports.sub("", p.read_text(encoding="utf-8")) for p in sources) + body
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [[["s1", ["g1"]]], ["s1", "s1__g1"]]
@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
"""B08 구조물 집계표 — 관경 · 련 빈칸 = 「미입력」(임의 1000 · 1 로 안 채움 · PLAN 143-2).
견본: 100 관 관경 빈칸 L16 · 유출 흙막이 높이 빈칸 / 200 세월교 Φ1000 련 빈칸 · 관 길이 9.
"""
from __future__ import annotations
import json
from pathlib import Path
from B08_Quantity import B08_Quantity_Router_Summary as summary
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
ITEM = {"layer": "system", "owner": None, "key": "SS000001", "name": "견본"}
POINTS = [
{
"chainage_m": 100.0,
"options": {
"pipe_diameter_mm": "",
"outlet_type": "기슭막이",
"outlet_revet_form": "돌쌓기(메)",
"outlet_revet_before_m": 5,
"outlet_revet_after_m": 5,
},
"summary_item": ITEM,
"group_items": {"g9lq9uk": ITEM},
},
{
"chainage_m": 200.0,
"facility": "ford_bridge",
"options": {"pipe_diameter_mm": 1000, "pipe_count": "", "ford_width_m": 12},
"summary_item": ITEM,
},
]
DESIGNS = [
{"chainage_m": 100.0, "design": {"pipe_length_m": 16}},
{"chainage_m": 200.0, "design": {"ford_pipe_length_m": 9}},
]
def _run(tmp_path: Path) -> dict:
root = tmp_path / "proj"
edits = root / "B04_PreProcess/drainage/edits"
edits.mkdir(parents=True)
points = [{**p, "source": "user"} for p in POINTS]
(edits / "pipe_points.json").write_text(
json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8"
)
names = definition.read()["종류"]["pipe"]["이름"]
linked = {owner: {} for owner in snapshots.assignments(root)}
return summary.pipe_summary(root, DESIGNS, names, 6.0, linked=linked)
def _cell(out: dict, at: float, cid: str):
row = next(r for r in out["rows"] if abs(r["chainage_m"] - at) < 0.01)
return row["values"].get(cid)
def test_관경_빈칸은_미입력_열(tmp_path):
out = _run(tmp_path)
cols = {c["id"]: c for c in out["columns"]}
assert cols["pipe:len|-"]["group"] == ["관 공", "미입력", "관매설"]
assert _cell(out, 100, "pipe:len|-") == 16
assert _cell(out, 100, "pipe:cp|-") == 2 # 1본 6 m — 관경과 무관
assert not any(c.startswith("pipe:len|1000") and _cell(out, 100, c) for c in cols)
# 흙막이 빈 높이 = 관경 최소 높이를 모름 → 「미입력」
assert cols["pipe:revet|돌쌓기(메)|-"]["group"][2] == "미입력"
assert _cell(out, 100, "pipe:revet|돌쌓기(메)|-") == 10
def test_세월교_련_빈칸은_미입력_관_몫_빈칸(tmp_path):
out = _run(tmp_path)
cols = {c["id"]: c for c in out["columns"]}
assert cols["pipe:ford|1000|-|width"]["group"][1] == "Φ1000×미입력련"
assert _cell(out, 200, "pipe:ford|1000|-|width") == 12
assert _cell(out, 200, "pipe:len|1000") is None # 련 수를 모르면 관 몫도 빈칸
assert _cell(out, 200, "pipe:cp|1000") is None
assert not [p for p in out["pipes"] if p["column"] == "pipe:cp|1000"]
@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
"""B08 구조물 탭 — 자재 셋 값 없는 줄 숨김 · 표 넷 공용 카드 안(PLAN 143-3).
숨김은 화면만 — 셈 · 열 · 단위수량은 그대로 · 고친 칸은 원래 줄 번호(`keep`)로 저장 자리를 찾음.
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8")
SUMMARY = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
def test_자재_셋_값_없는_줄_숨김():
assert MATERIAL.count("const keep = shown(rows, ") == 3 # 총괄 · 콘크리트 · 자재수량
assert "row.qty != null || anyValue(row.units)" in MATERIAL # 손 단위수량 있으면 보임
assert "row.qty != null || anyValue(row.amounts)" in MATERIAL
assert "row.qty != null || row.amount != null" in MATERIAL
# 고친 칸 → 원래 줄(숨긴 줄 건너뜀)
for target in (
"rows[keep[i / 2]].id",
"rows[keep[i]].key, key.slice(4)",
"rows[keep[i]].panel",
):
assert target in MATERIAL, target
def test_표_넷_공용_카드_안():
assert "createCard({ body: [toolbar, table.root] })" in MATERIAL
assert "createCard({ body: parts })" in SUMMARY
@@ -32,10 +32,14 @@ def test_read_only():
assert "editable: () => false" in DRAWING
def test_copy_first_then_master():
# 같은 항목의 프로젝트 복사본이 있으면 복사본 · 없으면 M02 원본 층
assert "sameItem(own, row)" in DRAWING and "structureFolder(" in DRAWING
assert "readTemplate(layer, part, column, null)" in DRAWING
def test_copy_only():
# 143-1 — 연결된 사본만(그룹은 `{id}__{그룹}` 사본) · 없으면 「사본 없음」 · M02 층 폴백 없음
assert (
"request.linked[combo.copyId]" in DRAWING
and "sourceOf(request.projectId, copy.폴더)" in DRAWING
)
assert "readTemplate" not in DRAWING and "FromMaster" not in DRAWING
assert "사본 없음" in LOCALE
def test_locale_keys_exist():
@@ -195,12 +195,15 @@ def test_토공집계표_식() -> None:
assert value[("토사운반", "", "19 TON B/D")] == 20
assert value[("유용토 소계", "", "")] == pytest.approx(t["diverted_m3"] + 82)
assert value[("잔토처리", "", "백호우")] == -98
assert value[("성토면다짐", "", "")] == excel_round(101.7 * 0.8)
assert value[("초류종자살포", "", "씨드스프레이")] == excel_round(101.7 * 0.5 + 15.3)
tree = excel_round(133.2 * 0.5 + 82.8, 2)
assert value[("지장목제거", "", "")] == excel_round(tree * 0.8)
# 143-2 — 거창 계수(0.8 · 0.5) 자리는 빈칸 + 「비율 미정」 · 구조물 쪽 줄 8 뺌 · 나머지 7 빈칸
notes = {r["group"]: r["note"] for r in rows}
for group in ("성토면다짐", "초류종자살포", "지장목제거"):
assert next(r["value"] for r in rows if r["group"] == group) is None
assert notes[group] == "비율 미정"
assert value[("표토제거", "", "")] is None
assert len(rows) == 33 # 시트 C5:C37
assert not {"구조물터파기", "토사다짐", "되메우기", "잔토"} & {r["group"] for r in rows}
assert sum(r["value"] is None and r["note"] != "비율 미정" for r in rows) == 7
assert len(rows) == 25 # 시트 C5:C37 33 − 구조물 쪽 8
def test_토적표_머리_글자는_사용자_견본대로():
@@ -56,15 +56,27 @@ def test_장은_시설이_정함() -> None:
def test_박스_울진_산출서_단면() -> None:
r = compute(_inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50))
assert SHEET_DEFAULTS["box"]["n"] == 0.013 and SHEET_DEFAULTS["box"]["depth_ratio"] == 0.8
# 143-2 — 수심비 처음 값 빈칸(울진 0.8 은 손으로 넣은 값으로만)
r = compute(
_inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50, depth_ratio=0.8)
)
assert SHEET_DEFAULTS["box"]["n"] == 0.013 and SHEET_DEFAULTS["box"]["depth_ratio"] is None
assert r["n"] == 0.013
assert r["depth"] == pytest.approx(1.2)
assert r["section_area"] == pytest.approx(3.6)
assert r["perimeter"] == pytest.approx(5.4)
assert r["radius"] == pytest.approx(3.6 / 5.4)
# 실무 29.236 = 1/n 75 · R 0.67 반올림 — 같은 n 이면 R 반올림 차(0.3 %)만 남음
r75 = compute(_inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50, n=1 / 75))
r75 = compute(
_inputs(
"box",
box_width_m=3.0,
box_height_m=1.5,
slope_denominator=50,
n=1 / 75,
depth_ratio=0.8,
)
)
assert r75["capacity"] == pytest.approx(29.236, rel=0.004)
assert 75 * 0.67 ** (2 / 3) * math.sqrt(1 / 50) * 3.6 == pytest.approx(29.236, abs=0.001)
assert r["capacity"] > r75["capacity"] # n 고치면 따라감
@@ -79,6 +91,9 @@ def test_박스_내공_경사_빈칸이면_대시() -> None:
r = compute(_inputs("box"))
assert r["design_flow"] is not None
assert r["depth"] is None and r["capacity"] is None and r["ok"] is None
# 수심비 빈칸 = 내공 · 경사가 있어도 계산 멈춤(143-2)
r = compute(_inputs("box", box_width_m=3.0, box_height_m=1.5, slope_denominator=50))
assert r["depth"] is None and r["capacity"] is None and r["ok"] is None
@pytest.fixture(scope="module")
+17 -3
View File
@@ -7,6 +7,8 @@
78+7 Φ800 L18 · 집수정 ㄷ(U) · 유출 메 H2.0 10 m · 메붙임 20 ㎡ → 커플링 2
84+0 Φ800 L16 · 집수정 ㄴ(L) · 유출 메 H2.0 → 커플링 올림(16/6)−1 = 2 · 1본 8 m 면 1
89+10 Φ800 L(없음) · 유출 높이 빈칸 → 관경 최소 1.8 · 집수정 기록 없음 → 알림
143-1 — 칸은 그 칸을 낸 조합 사본이 있을 때만 셈: 픽스처는 관 · 유입 · 유출 · 흙막이 1 · 돌붙임 1 을
모두 잇고 연결된 사본 = 지정 전부(`_run`) · 사본 없으면 없음은 test_143_1
"""
from __future__ import annotations
@@ -17,12 +19,15 @@ from pathlib import Path
import pytest
from B08_Quantity import B08_Quantity_Router_Summary as summary
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
from M02_MasterTemplete import M02_Template_Layers as layers
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
COMBO = {"layer": "system", "owner": None, "key": "SS000011", "name": "횡단배수 배관"}
OUT = {"layer": "company", "owner": "3", "key": "SC3-000001", "name": "기슭막이 메쌓기"}
WALL = {"side": "outlet", "key": "extra0"}
#: 컨테이너 잇기 전부(143-1 — 잇지 않은 칸은 안 셈) · 흙막이 칸 조합 = OUT
LINKED = {"gnbf9m9": COMBO, "g9lq9uk": OUT, "gsoiladd1": OUT, "gchuteadd1": COMBO}
def _revet(side: str, form: str, height: float | None) -> dict:
@@ -101,7 +106,15 @@ def root(tmp_path: Path) -> Path:
found = tmp_path / "proj"
edits = found / "B04_PreProcess/drainage/edits"
edits.mkdir(parents=True)
points = [{**p, "source": "user"} for p in POINTS]
points = [
{
**p,
"source": "user",
"summary_item": COMBO,
"group_items": {**LINKED, **(p.get("group_items") or {})},
}
for p in POINTS
]
(edits / "pipe_points.json").write_text(
json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8"
)
@@ -110,7 +123,8 @@ def root(tmp_path: Path) -> Path:
def _run(root: Path, one_m: float | None = 6.0) -> dict:
names = definition.read()["종류"]["pipe"]["이름"]
return summary.pipe_summary(root, DESIGNS, names, one_m)
linked = {owner: {} for owner in snapshots.assignments(root)} # 지정 전부 사본 있음
return summary.pipe_summary(root, DESIGNS, names, one_m, linked=linked)
def _cell(out: dict, at: float, cid: str):
@@ -201,7 +215,7 @@ def test_1본_길이는_프로젝트_층_변수(root, tmp_path, monkeypatch):
json.dumps({"변수": {"파형강관_1본_m": 8}}), encoding="utf-8"
)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
assert summary.one_pipe_length(root) == 8
assert summary.one_pipe_length(root) is None # 143-2 — 시스템 층으로 안 채움
mine = layers.template_path(layers.project_dir(root), "table", "구조물집계표")
mine.parent.mkdir(parents=True)
mine.write_text(json.dumps({"변수": {"파형강관_1본_m": 6}}), encoding="utf-8")
+5 -6
View File
@@ -28,7 +28,6 @@ export const ui_locales_b4 = {
B08_Quantity_Tab_MaterialQuantity: ["자재수량집계표", "Material Quantity Table"],
B08_Quantity_Sheets_Title: ["구조물도", "Structure Sheets"],
B08_Quantity_Sheets_FromCopy: ["프로젝트 복사본", "Project copy"],
B08_Quantity_Sheets_FromMaster: ["M02 원본", "M02 original"],
B08_Quantity_Sheets_NoCopy: ["복사본에 없음", "Not in the project copy"],
B08_Quantity_Drawing_Loading: ["구조물도를 불러오는 중…", "Loading structure sheets…"],
B08_Quantity_Drawing_Failed: [
@@ -36,15 +35,15 @@ export const ui_locales_b4 = {
"Failed to load structure sheets",
],
B08_Quantity_Drawing_Missing: [
"M02 에 없는 조합입니다 — 도면 · 면적산출을 보일 수 없습니다.",
"This combination is not in M02 — no drawing or area sheet to show.",
"사본 없음 — B05 · B06 좌측 패널에서 조합 연결 · [저장] 뒤 보입니다.",
"No project copy — link the combination in the B05 · B06 panel and save.",
],
B08_Quantity_Drawing_NoDrawing: [
"이 조합은 도면이 없습니다 — M02 구조물 조합에서 그립니다.",
"This combination has no drawing — draw it in M02 structure combinations.",
"이 조합 사본은 도면이 없습니다 — M02 구조물 조합에서 그린 뒤 [재적용].",
"This copy has no drawing — draw it in M02 structure combinations, then reapply.",
],
B08_Quantity_Summary_Station: ["측점", "Station"],
B08_Quantity_Summary_MissingGroup: ["M02 에 없음", "Not in M02"],
B08_Quantity_Summary_MissingGroup: ["경로 없음", "No path"],
B08_Quantity_Summary_Loading: ["구조물 집계표를 불러오는 중…", "Loading the structure summary…"],
B08_Quantity_Summary_Failed: [
"구조물 집계표를 불러오지 못했습니다",