Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1
# Conflicts: # B08_Quantity/B08_Quantity_UI_Page.css
This commit is contained in:
@@ -569,6 +569,8 @@ export interface CrossSectionPatch {
|
||||
/** B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 관 길이와 같은 길. */
|
||||
pipe_slope_pct?: number;
|
||||
box_ground_slope_pct?: number;
|
||||
/** B08 구조물 집계표 BOX 본체 길이(m · 120) — 박스 두 끝 offset 차 · 같은 길. */
|
||||
box_length_m?: number;
|
||||
inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
|
||||
basin_adjust?: {
|
||||
innerWidthM: number;
|
||||
|
||||
@@ -188,6 +188,8 @@ class CrossSectionPatch(BaseModel):
|
||||
# (관 길이와 같은 길). 0 · 음수도 그대로 — 빈칸 판정은 B08 몫.
|
||||
pipe_slope_pct: float | None = None
|
||||
box_ground_slope_pct: float | None = None
|
||||
# B08 구조물 집계표 BOX 본체 길이(120) — 박스 두 끝 offset 차(m) · 같은 길.
|
||||
box_length_m: float | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class SectionConfirmRequest(BaseModel):
|
||||
|
||||
@@ -68,6 +68,7 @@ _AREA_KEYS = (
|
||||
"pipe_length_m",
|
||||
"pipe_slope_pct",
|
||||
"box_ground_slope_pct",
|
||||
"box_length_m",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -159,6 +159,8 @@ export const STRUCTURE_ROW_KEYS = [
|
||||
// B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 같은 길로 실음.
|
||||
"pipe_slope_pct",
|
||||
"box_ground_slope_pct",
|
||||
// B08 구조물 집계표 BOX 본체 길이(120) — 박스 두 끝(구체 끝) offset 차 · 같은 길로 실음.
|
||||
"box_length_m",
|
||||
] as const;
|
||||
|
||||
/** 박스 두 끝(구체 끝) 원지반 경사(%) — 유입 − 유출 ÷ 수평거리. 본체는 늘 수평이라 원지반으로(110). */
|
||||
@@ -171,6 +173,14 @@ function boxGroundSlopePct(section: CrossSection, box: BoxLayout): number | null
|
||||
return ((groundAt(inlet.offset) - groundAt(outlet.offset)) / run) * 100;
|
||||
}
|
||||
|
||||
/** 박스 본체 길이(m) — 두 끝(구체 끝) offset 차 · 한쪽이라도 없으면 null(120). */
|
||||
function boxLengthM(box: BoxLayout): number | null {
|
||||
const inlet = box.sides.find((side) => side.wingRole === "inlet");
|
||||
const outlet = box.sides.find((side) => side.wingRole === "outlet");
|
||||
const run = inlet && outlet ? Math.abs(inlet.offset - outlet.offset) : 0;
|
||||
return run > 0 ? run : null;
|
||||
}
|
||||
|
||||
/** 폐회로 면적 입력(설계선·지반선·트림) — 트림이나 설계선이 모자라면 null. */
|
||||
function areaInputOf(section: CrossSection, layouts: StoredLayouts) {
|
||||
const trim = trimOfLayouts(layouts);
|
||||
@@ -228,6 +238,9 @@ function areaRowOf(
|
||||
? boxGroundSlopePct(section, layouts.box)
|
||||
: null;
|
||||
if (boxSlope !== null) ownRow.box_ground_slope_pct = Number(boxSlope.toFixed(4));
|
||||
const boxLength =
|
||||
layouts.box && isOwner(section.box?.chainage_m) ? boxLengthM(layouts.box) : null;
|
||||
if (boxLength !== null) ownRow.box_length_m = Number(boxLength.toFixed(4));
|
||||
const pipeRow = Object.keys(ownRow).length > 1 ? ownRow : null;
|
||||
const areaInput = areaInputOf(section, layouts);
|
||||
if (!areaInput) return pipeRow;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""B08 구조물 집계표 길(PLAN 98-1) — 읽기만 · 계산 서버 단독(CLAUDE.md 5장 ③).
|
||||
|
||||
GET /api/projects/{id}/quantity/summary
|
||||
배관(횡단배수관) 한 곳마다 컨테이너 값 → 실무 구조물집계표 꼴 열 · 측점 줄 · 계
|
||||
횡단배수 한 곳마다 컨테이너 값 → 실무 구조물집계표 꼴 열 · 측점 줄 · 계
|
||||
|
||||
열 = 설계에 쓰인 값만 펼침 · 머리 = `group`[구조물, 형식, 규격] + `label`(단위 줄):
|
||||
흙막이 › 형태 › H=높이 m 유입(흙막이일 때) · 유출 · 흙막이 n(실제 선 단까지)
|
||||
@@ -10,7 +10,15 @@
|
||||
관 공 › Φ{관경}m/m › 관매설 m B06 관 길이(1 m 올림 그대로)
|
||||
› 커플링 ea 올림(L ÷ 1본) − 1 · 1본 = 프로젝트 층 M02 구조물집계표 변수
|
||||
› 관보호공 모양 실제로 선 집수정 모양마다 1 개소(98-2 `inlet_basin`)
|
||||
머리 글 = M02 정의 이름(`구조물정의.json` 종류.pipe.이름 — 사용자가 M02 에서 고침) ·
|
||||
BOX암거 › {가로×세로} › 본체 m B06 새 키 `box_length_m`(두 끝 offset 차 · 120)
|
||||
› 날개벽 › 유입 · 유출 개소
|
||||
세월교 › Φ{관경}×{n}련 › 월류 폭 m · 본체 개소 · 날개벽 › 유입 · 유출 개소
|
||||
물넘이포장 › 월류 폭 › H={월류 높이} m
|
||||
노출형 횡단수로 · 개거 · 맹암거(B05 구조물) 개소
|
||||
흙막이 · 돌붙임 열은 관 · 박스 · 세월교 공용(실무 꼴) — 빈 높이: 관 = 관경 최소 ·
|
||||
박스 = 박스 높이 · 세월교 = 「미입력」(본체 끝 높이는 기하에만)
|
||||
열 id 접두 `pipe:` = 이 집계표(횡단배수) 이름공간 · 같은 측점 박스 + 관은 종류로 갈라 셈.
|
||||
머리 글 = M02 정의 이름(`구조물정의.json` types.name · 종류.{종류}.이름 — 사용자가 M02 에서 고침) ·
|
||||
열마다 `combos` = 그 값을 낸 컨테이너에 이은 조합 id(`{층}|{주인}|{키}` · 95-3 모달 · 맨 윗 머리).
|
||||
설계 = B08 토공과 같은 길(확정 노선 먼저) · 관 · 컨테이너 줄 = M02 채움 `collect_items` 재사용.
|
||||
"""
|
||||
@@ -47,6 +55,19 @@ ONE_LENGTH_VAR = "파형강관_1본_m"
|
||||
#: 필수 컨테이너 잇기 키(`design_structure_pipe.ts` link) — 관 · 유입 · 유출
|
||||
LINKS = {"pipe": "grv6lny", "inlet": "gnbf9m9", "outlet": "g9lq9uk"}
|
||||
SOIL = "기슭막이" # 저장 값(화면 이름 흙막이)
|
||||
GUARD_SOIL = "흙막이" # 박스 · 세월교 유입 · 유출 구조 값
|
||||
WING = "날개벽"
|
||||
#: 박스 · 세월교 필수 컨테이너 잇기 키(`design_structure_box` · `_ford`) · 물넘이 경사각도
|
||||
BOX_LINKS = {"body": "gboxbody", "inlet": "gboxin", "outlet": "gboxout"}
|
||||
FORD_LINKS = {"body": "gford", "inlet": "gfordin", "outlet": "gfordout"}
|
||||
PAVE_LINKS = {"body": "gfordpaveslope"}
|
||||
#: 옛 날개벽 설치 칸(「있음」 · 「없음」)
|
||||
WING_KEYS = {"inlet": "wing_in", "outlet": "wing_out"}
|
||||
SIDE_NAMES = {"inlet": "유입", "outlet": "유출"}
|
||||
#: 개소 1 로 세는 B05 구조물(structures.json · 옵션 거의 없음)
|
||||
SIMPLE = ("cross_drain_exposed", "open_ditch", "cross_underdrain")
|
||||
#: 이 집계표가 펼치는 종류 — 화면은 이 종류를 조합 열에서 뺌(응답 `types`)
|
||||
DRAIN_TYPES = ("pipe", "box_culvert", "ford_bridge", "ford_pavement", *SIMPLE)
|
||||
FORMS = ("돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자")
|
||||
SHAPES = ("I", "L", "U")
|
||||
BONDS = ("찰붙임", "메붙임")
|
||||
@@ -95,6 +116,31 @@ def _basins(designs: Any) -> dict[float, str]:
|
||||
return found
|
||||
|
||||
|
||||
def _station_values(designs: Any, key: str) -> dict[float, float]:
|
||||
"""설계 → `{측점: 양수 값}` — 값 없는 측점은 안 담음."""
|
||||
found: dict[float, float] = {}
|
||||
for row in designs or []:
|
||||
design = row.get("design") if isinstance(row, dict) else None
|
||||
value = _num(design.get(key)) if isinstance(design, dict) else None
|
||||
if value and value > 0:
|
||||
found[round(float(row.get("chainage_m") or 0.0), 3)] = value
|
||||
return found
|
||||
|
||||
|
||||
def _guard(type_id: str, options: dict[str, Any], side: str) -> str:
|
||||
"""박스 · 세월교 유입 · 유출 구조(없음 · 날개벽 · 흙막이) — 옛 설치 칸 「있음」 = 날개벽.
|
||||
짝(TS): 박스 `boxOptionsIn` · 세월교 `withLegacyGuards`(`A00_Common/design/`)."""
|
||||
wing = options.get(WING_KEYS[side])
|
||||
if type_id == "box_culvert":
|
||||
return WING if wing == "있음" else str(wing or "")
|
||||
kind = str(options.get(f"{side}_type") or "")
|
||||
if wing == "있음":
|
||||
return WING
|
||||
if wing == "없음" and (kind or WING) == WING:
|
||||
return "없음"
|
||||
return kind
|
||||
|
||||
|
||||
def _revet_length(options: dict[str, Any], head: str, unit: str) -> float | None:
|
||||
"""흙막이 길이 = 전 + 후 · 둘 다 비면 길이 칸."""
|
||||
before = _num(options.get(f"{head}before{unit}"))
|
||||
@@ -150,46 +196,281 @@ def _order(values: tuple[str, ...], value: str) -> int:
|
||||
return values.index(value) if value in values else len(values)
|
||||
|
||||
|
||||
def _revets(
|
||||
sheet: _Sheet,
|
||||
at: float,
|
||||
options: dict[str, Any],
|
||||
links: dict[str, str | None],
|
||||
sides: list[tuple[str, str | None]],
|
||||
empty_height: float | None,
|
||||
names: dict[str, str],
|
||||
) -> None:
|
||||
"""흙막이 — 유입 · 유출(흙막이일 때 · `sides`) + 흙막이 n(container_rows 가 선 단까지만 냄).
|
||||
|
||||
저장 키 = 필수는 `{쪽}_revet_{칸}_m` · 선택 흙막이는 `soil_add_{n}_{칸}`(명세 인스턴스 키) ·
|
||||
빈 높이 = `empty_height`(None 이면 「미입력」 열) · 머리 글은 관 정의 이름(공용 열).
|
||||
"""
|
||||
sources = [(f"{side}_revet_", "_m", side, combo) for side, combo in sides]
|
||||
sources += [
|
||||
(f"soil_add_{link[len('gsoiladd') :]}_", "", "soil_add", combo)
|
||||
for link, combo in links.items()
|
||||
if link.startswith("gsoiladd")
|
||||
]
|
||||
for head, unit, container, combo in sources:
|
||||
form = str(options.get(f"{head}form") or "")
|
||||
height = _num(options.get(f"{head}height{unit}"))
|
||||
if height is None:
|
||||
height = empty_height
|
||||
height_text = fill._label("height_m", height)
|
||||
sheet.add(
|
||||
at,
|
||||
f"revet|{form or fill.EMPTY_LABEL}|{height_text}",
|
||||
(0, _order(FORMS, form), form, math.inf if height is None else height),
|
||||
[
|
||||
names.get(f"outlet.structure={SOIL}", SOIL),
|
||||
names.get(f"{container}.form={form}", form) or fill.EMPTY_TEXT,
|
||||
fill.EMPTY_TEXT if height is None else f"H={height_text}",
|
||||
],
|
||||
"m",
|
||||
_revet_length(options, head, unit),
|
||||
[combo],
|
||||
)
|
||||
|
||||
|
||||
def _chutes(
|
||||
sheet: _Sheet,
|
||||
at: float,
|
||||
options: dict[str, Any],
|
||||
links: dict[str, str | None],
|
||||
names: dict[str, str],
|
||||
) -> None:
|
||||
"""돌붙임 — 붙임 형식 · 면적이 다 있을 때만(설계자가 넣음)."""
|
||||
for link, combo in links.items():
|
||||
if not link.startswith("gchuteadd"):
|
||||
continue
|
||||
head = f"chute_add_{link[len('gchuteadd') :]}_"
|
||||
bond = str(options.get(f"{head}bond") or "")
|
||||
area = _num(options.get(f"{head}area"))
|
||||
if options.get(f"{head}structure") != "돌붙임" or not bond or not area:
|
||||
continue
|
||||
sheet.add(
|
||||
at,
|
||||
f"chute|{bond}",
|
||||
(1, _order(BONDS, bond), bond),
|
||||
[
|
||||
names.get("chute_add.structure=돌붙임", "돌붙임"),
|
||||
names.get(f"chute_add.bond={bond}", bond),
|
||||
],
|
||||
"㎡",
|
||||
area,
|
||||
[combo],
|
||||
)
|
||||
|
||||
|
||||
def _wings(
|
||||
sheet: _Sheet,
|
||||
at: float,
|
||||
type_id: str,
|
||||
sort: int,
|
||||
title: str,
|
||||
options: dict[str, Any],
|
||||
combos: dict[str, str | None],
|
||||
names: dict[str, str],
|
||||
) -> list[tuple[str, str | None]]:
|
||||
"""박스 · 세월교 날개벽(유입 · 유출 개소) — 흙막이 쪽은 돌려줌(공용 흙막이 열로)."""
|
||||
soils: list[tuple[str, str | None]] = []
|
||||
for index, side in enumerate(("inlet", "outlet")):
|
||||
kind = _guard(type_id, options, side)
|
||||
if kind == GUARD_SOIL:
|
||||
soils.append((side, combos.get(side)))
|
||||
elif kind == WING:
|
||||
sheet.add(
|
||||
at,
|
||||
f"{type_id}|wing|{side}",
|
||||
(sort, 9, index),
|
||||
[title, names.get(f"{side}.structure={WING}", WING), SIDE_NAMES[side]],
|
||||
"개소",
|
||||
1.0,
|
||||
[combos.get(side)],
|
||||
)
|
||||
return soils
|
||||
|
||||
|
||||
def _box(
|
||||
sheet: _Sheet,
|
||||
at: float,
|
||||
base: dict[str, Any],
|
||||
combos: dict[str, str | None],
|
||||
names: dict[str, str],
|
||||
length: float | None,
|
||||
) -> float | None:
|
||||
"""BOX암거 본체(가로 × 세로마다 m) — 흙막이 빈 높이로 쓸 박스 높이를 돌려줌."""
|
||||
width = _num(base["options"].get("body_width_m"))
|
||||
height = _num(base["options"].get("body_height_m"))
|
||||
size = (
|
||||
f"{fill._label('width_m', width)}×{fill._label('height_m', height)}"
|
||||
if width or height
|
||||
else fill.EMPTY_LABEL
|
||||
)
|
||||
sheet.add(
|
||||
at,
|
||||
f"box|body|{size}",
|
||||
(3, 0, width or 0.0, height or 0.0),
|
||||
[
|
||||
names["종류"],
|
||||
names.get(f"body.size={size}", fill.EMPTY_TEXT if size == fill.EMPTY_LABEL else size),
|
||||
"본체",
|
||||
],
|
||||
"m",
|
||||
length,
|
||||
[_combo(base.get("summary_item")), combos.get("body")],
|
||||
)
|
||||
return height
|
||||
|
||||
|
||||
def _ford(
|
||||
sheet: _Sheet,
|
||||
at: float,
|
||||
base: dict[str, Any],
|
||||
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}련"
|
||||
width_name = names.get("ford.width", "월류 폭").split(" (")[0]
|
||||
cells = (
|
||||
("width", width_name, "m", _num(base["options"].get("ford_width_m"))),
|
||||
("count", "본체", "개소", 1.0),
|
||||
)
|
||||
for index, (key, label, unit, value) in enumerate(cells):
|
||||
sheet.add(
|
||||
at,
|
||||
f"ford|{diameter}|{count}|{key}",
|
||||
(4, 0, diameter, count, index),
|
||||
[names["종류"], spec, label],
|
||||
unit,
|
||||
value,
|
||||
[_combo(base.get("summary_item")), combos.get("body")],
|
||||
)
|
||||
|
||||
|
||||
def _pavement(
|
||||
sheet: _Sheet,
|
||||
at: float,
|
||||
base: dict[str, Any],
|
||||
combos: dict[str, str | None],
|
||||
names: dict[str, str],
|
||||
) -> None:
|
||||
"""물넘이 포장 — 월류 높이(H)마다 월류 폭 m(실무 「물넘이 포장 H=1.0」)."""
|
||||
height = _num(base["options"].get("ford_height_m"))
|
||||
height_text = fill._label("height_m", height)
|
||||
sheet.add(
|
||||
at,
|
||||
f"pave|{height_text}",
|
||||
(5, math.inf if height is None else height),
|
||||
[
|
||||
names["종류"],
|
||||
names.get("slope.width", "월류 폭").split(" (")[0],
|
||||
fill.EMPTY_TEXT if height is None else f"H={height_text}",
|
||||
],
|
||||
"m",
|
||||
_num(base["options"].get("ford_width_m")),
|
||||
[_combo(base.get("summary_item")), combos.get("body")],
|
||||
)
|
||||
|
||||
|
||||
def _drain(
|
||||
sheet: _Sheet,
|
||||
at: float,
|
||||
type_id: str,
|
||||
base: dict[str, Any],
|
||||
options: dict[str, Any],
|
||||
links: dict[str, str | None],
|
||||
group: dict[str, Any],
|
||||
kinds: dict[str, Any],
|
||||
pipe_names: dict[str, str],
|
||||
box_length: float | None,
|
||||
) -> None:
|
||||
"""관 밖 횡단배수 한 곳 — 종류마다 본체 열 + 박스 · 세월교는 날개벽 · 흙막이 · 돌붙임(공용)."""
|
||||
title = next(
|
||||
(t["name"] for t in kinds.get("types") or [] if t.get("type_id") == type_id), type_id
|
||||
)
|
||||
names = {**(((kinds.get("종류") or {}).get(type_id) or {}).get("이름") or {}), "종류": title}
|
||||
if type_id in SIMPLE:
|
||||
combos = [_combo(base.get("summary_item")), *links.values()]
|
||||
sheet.add(at, type_id, (6, SIMPLE.index(type_id)), [title], "개소", 1.0, combos)
|
||||
return
|
||||
# 필수 컨테이너 잇기 — 정의 그룹에 없는 것(세월교)은 저장된 잇기에서
|
||||
required = {"box_culvert": BOX_LINKS, "ford_bridge": FORD_LINKS}.get(type_id, PAVE_LINKS)
|
||||
combos = {part: links.get(link) or _combo(group.get(link)) for part, link in required.items()}
|
||||
if type_id == "ford_pavement":
|
||||
_pavement(sheet, at, base, combos, names)
|
||||
return
|
||||
if type_id == "box_culvert":
|
||||
empty_height = _box(sheet, at, base, combos, names, box_length)
|
||||
else:
|
||||
_ford(sheet, at, base, combos, names)
|
||||
empty_height = None # 본체 끝 높이는 기하에만 — 「미입력」 열
|
||||
sort = 3 if type_id == "box_culvert" else 4
|
||||
soils = _wings(sheet, at, type_id, sort, title, base["options"], combos, names)
|
||||
_revets(sheet, at, options, links, soils, empty_height, pipe_names)
|
||||
_chutes(sheet, at, options, links, pipe_names)
|
||||
|
||||
|
||||
def pipe_summary(
|
||||
project_root: str | Path,
|
||||
designs: list[dict[str, Any]],
|
||||
names: dict[str, str],
|
||||
one_m: float | None,
|
||||
kinds: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""배관 한 곳마다 컨테이너 값 → 집계표 열 · 줄 · 계(저장 안 함)."""
|
||||
"""횡단배수 한 곳마다 컨테이너 값 → 집계표 열 · 줄 · 계(저장 안 함).
|
||||
|
||||
`names` = 관 정의 이름(공용 흙막이 · 돌붙임 머리) · `kinds` = M02 정의(비면 읽음).
|
||||
"""
|
||||
kinds = kinds or definition.read()
|
||||
items = fill.collect_items(
|
||||
project_root,
|
||||
fill.pipe_lengths_from_designs(designs),
|
||||
soil_tiers=soil_tiers_from_designs(designs),
|
||||
)
|
||||
# 높이 등 빈 칸은 정의 기본값이 아니라 그림 규칙을 따라야 해 저장 그대로를 따로 읽음
|
||||
# 같은 측점 박스 + 관은 복사본 id(`pipe@측점`)가 같음 — 종류로 가름
|
||||
raw = {
|
||||
f"pipe@{round(float(point.chainage_m), 3)}": dict(point.options or {})
|
||||
(point.facility or "pipe", f"pipe@{round(float(point.chainage_m), 3)}"): point
|
||||
for point in read_pipe_points_file(pipe_points_path_in(Path(project_root)))
|
||||
if (point.facility or "pipe") == "pipe"
|
||||
}
|
||||
basins = _basins(designs)
|
||||
by_owner: dict[str, dict[str, Any]] = {}
|
||||
box_lengths = _station_values(designs, "box_length_m")
|
||||
by_owner: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for item in items:
|
||||
if item["type_id"] != "pipe":
|
||||
if item["type_id"] not in DRAIN_TYPES:
|
||||
continue
|
||||
owner, _, link = item["복사본"].partition(GROUP_JOIN)
|
||||
entry = by_owner.setdefault(owner, {"links": {}})
|
||||
entry = by_owner.setdefault((item["type_id"], owner), {"links": {}})
|
||||
if link:
|
||||
entry["links"][link] = _combo(item.get("summary_item"))
|
||||
else:
|
||||
entry["base"] = item
|
||||
|
||||
sheet = _Sheet()
|
||||
no_length = no_basin = 0
|
||||
soil_name = names.get(f"outlet.structure={SOIL}", SOIL)
|
||||
for owner, entry in by_owner.items():
|
||||
no_length = no_basin = no_box = 0
|
||||
for (type_id, owner), entry in by_owner.items():
|
||||
base = entry.get("base")
|
||||
if base is None:
|
||||
continue
|
||||
links = entry["links"]
|
||||
options = raw.get(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 {}
|
||||
_drain(sheet, at, type_id, base, options, links, group, kinds, names, box_length)
|
||||
continue
|
||||
diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000)
|
||||
pipe_head = ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"]
|
||||
pipe_combos = [_combo(base.get("summary_item")), links.get(LINKS["pipe"])]
|
||||
@@ -226,59 +507,10 @@ def pipe_summary(
|
||||
[links.get(LINKS["inlet"])],
|
||||
)
|
||||
|
||||
# 흙막이 — 유입(흙막이일 때) · 유출 · 흙막이 n(container_rows 가 실제 선 단까지만 냄)
|
||||
# 저장 키 = 필수는 `{쪽}_revet_{칸}_m` · 선택 흙막이는 `soil_add_{n}_{칸}`(명세 인스턴스 키)
|
||||
sources = [
|
||||
(f"{side}_revet_", "_m", side, links.get(LINKS[side]))
|
||||
for side in ("inlet", "outlet")
|
||||
if options.get(f"{side}_type") == SOIL
|
||||
]
|
||||
sources += [
|
||||
(f"soil_add_{link[len('gsoiladd') :]}_", "", "soil_add", combo)
|
||||
for link, combo in links.items()
|
||||
if link.startswith("gsoiladd")
|
||||
]
|
||||
for head, unit, container, combo in sources:
|
||||
form = str(options.get(f"{head}form") or "")
|
||||
height = _num(options.get(f"{head}height{unit}"))
|
||||
if height is None:
|
||||
height = pipe_wall_min_height_m(diameter / 1000)
|
||||
height_text = fill._label("height_m", height)
|
||||
sheet.add(
|
||||
at,
|
||||
f"revet|{form or fill.EMPTY_LABEL}|{height_text}",
|
||||
(0, _order(FORMS, form), form, height),
|
||||
[
|
||||
soil_name,
|
||||
names.get(f"{container}.form={form}", form) or fill.EMPTY_TEXT,
|
||||
f"H={height_text}",
|
||||
],
|
||||
"m",
|
||||
_revet_length(options, head, unit),
|
||||
[combo],
|
||||
)
|
||||
|
||||
# 돌붙임 — 붙임 형식 · 면적이 다 있을 때만(설계자가 넣음)
|
||||
for link, combo in links.items():
|
||||
if not link.startswith("gchuteadd"):
|
||||
continue
|
||||
head = f"chute_add_{link[len('gchuteadd') :]}_"
|
||||
bond = str(options.get(f"{head}bond") or "")
|
||||
area = _num(options.get(f"{head}area"))
|
||||
if options.get(f"{head}structure") != "돌붙임" or not bond or not area:
|
||||
continue
|
||||
sheet.add(
|
||||
at,
|
||||
f"chute|{bond}",
|
||||
(1, _order(BONDS, bond), bond),
|
||||
[
|
||||
names.get("chute_add.structure=돌붙임", "돌붙임"),
|
||||
names.get(f"chute_add.bond={bond}", bond),
|
||||
],
|
||||
"㎡",
|
||||
area,
|
||||
[combo],
|
||||
)
|
||||
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)
|
||||
_chutes(sheet, at, options, links, names)
|
||||
|
||||
out = sheet.result()
|
||||
notes = []
|
||||
@@ -286,14 +518,17 @@ def pipe_summary(
|
||||
notes.append(f"관 연장 없음 {no_length}곳 — B06 횡단 [저장] 뒤 채워짐")
|
||||
if no_basin:
|
||||
notes.append(f"집수정 모양 기록 없음 {no_basin}곳 — B06 횡단 [저장] 뒤 채워짐")
|
||||
if no_box:
|
||||
notes.append(f"BOX 본체 길이 없음 {no_box}곳 — B06 횡단 [저장] 뒤 채워짐")
|
||||
if one_m is None:
|
||||
notes.append(f"1본 길이 변수({ONE_LENGTH_VAR}) 빈칸 — 커플링 빈칸")
|
||||
return {**out, "notes": notes, "one_length_m": one_m}
|
||||
return {**out, "notes": notes, "one_length_m": one_m, "types": list(DRAIN_TYPES)}
|
||||
|
||||
|
||||
def _summary(root: Path, designs: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
names = ((definition.read().get("종류") or {}).get("pipe") or {}).get("이름") or {}
|
||||
return pipe_summary(root, designs, names, one_pipe_length(root))
|
||||
kinds = definition.read()
|
||||
names = ((kinds.get("종류") or {}).get("pipe") or {}).get("이름") or {}
|
||||
return pipe_summary(root, designs, names, one_pipe_length(root), kinds)
|
||||
|
||||
|
||||
@router.get("/{project_id}/quantity/summary")
|
||||
|
||||
@@ -115,6 +115,13 @@ ${comboId(item)}`;
|
||||
return found;
|
||||
}
|
||||
|
||||
/** 글 폭 추정 — 한글 12px · 그 밖 7px + 여백 16(글 12px · 칸 여백 8 × 2). */
|
||||
export const textWidth = (text: string): number =>
|
||||
[...text].reduce((sum, ch) => sum + (/[ㄱ-ㆎ가-힣]/.test(ch) ? 12 : 7), 16);
|
||||
/** 열 폭 — 맨 아래 단 글 · 칸 글 중 가장 긴 것 · 44~100(넘는 머리는 줄바꿈 · 120-2). */
|
||||
export const widthOf = (...texts: string[]): number =>
|
||||
Math.min(100, Math.max(44, ...texts.map(textWidth)));
|
||||
|
||||
/** 머리 첫 줄 — 이어진 같은 구분을 한 칸으로 `[구분, 열 수]`. */
|
||||
export function groupSpans(columns: readonly ComboColumn[]): Array<[string, number]> {
|
||||
const spans: Array<[string, number]> = [];
|
||||
|
||||
@@ -31,3 +31,9 @@
|
||||
.b08-material__toolbar > .ui-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* 구조물 집계표 머리 — 좁은 열(44~100)에서 말줄임 대신 줄바꿈(120-2 · 공용 표 CSS 는 그대로) */
|
||||
[data-b08-summary="table"] thead th {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
*
|
||||
* 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만)
|
||||
* 머리 두 줄 = 조합 구분(M02 경로 첫 칸) · 조합 이름(나머지 경로) — `GET /api/m02/items?all=1`
|
||||
* 배관(횡단배수관) 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98) — 머리 구조물 · 형식 · 규격
|
||||
* 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 다른 구조물 열은 조합 그대로
|
||||
* 횡단배수 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98 · 120) — 머리 구조물 · 형식 · 규격
|
||||
* 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 서버가 펼친 종류(`types`)는
|
||||
* 조합 열에서 뺌 · 다른 구조물 열은 조합 그대로
|
||||
* 열 폭 = 맨 아래 단 글(+ 칸 값)만 잼 · 위 단 머리는 줄바꿈(`B08_Quantity_UI_Page.css` · 120-2)
|
||||
* 줄을 누르면 그 측점의 구조물도 모달(PLAN 95-3 · `B08_Quantity_UI_Drawing` 은 누를 때 `import()`)
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
type ComboLabel,
|
||||
type PlacedItem,
|
||||
type StationCombo,
|
||||
widthOf,
|
||||
} from "./B08_Quantity_Summary_Data";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
@@ -50,9 +53,6 @@ function note(text: string): HTMLElement {
|
||||
return node;
|
||||
}
|
||||
|
||||
/** 머리 글 길이에 맞춘 열 폭 — 칸은 비어 있어 글이 폭을 정함. */
|
||||
const widthOf = (label: string): number => Math.min(200, Math.max(96, label.length * 13 + 24));
|
||||
|
||||
/** 서버 배관 집계(PLAN 98-1) — 열 머리 `group`(구조물 · 형식 · 규격) + `label`(단위) · 열마다 조합 id. */
|
||||
interface PipeColumn {
|
||||
id: string;
|
||||
@@ -65,6 +65,8 @@ interface PipeSummary {
|
||||
rows: Array<{ chainage_m: number; values: Record<string, number> }>;
|
||||
total: Record<string, number>;
|
||||
notes: string[];
|
||||
/** 서버가 펼친 종류(`type_id` · 관 지점은 `facility`) — 조합 열에서 뺌 */
|
||||
types: string[];
|
||||
}
|
||||
|
||||
async function fetchPipeSummary(projectId: string): Promise<PipeSummary> {
|
||||
@@ -80,8 +82,6 @@ const stationKey = (station: number): number => Math.round(station * 100);
|
||||
/** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */
|
||||
const placesOf = (values: number[]): number =>
|
||||
Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length));
|
||||
const longest = (texts: string[]): string =>
|
||||
texts.reduce((a, b) => (b.length > a.length ? b : a), "");
|
||||
|
||||
/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(M02 에 있으면 · 조합 잇기 98). */
|
||||
function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] {
|
||||
@@ -97,14 +97,21 @@ function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | n
|
||||
...summary.rows.map((row) => row.values[column.id]),
|
||||
summary.total[column.id],
|
||||
].filter((value): value is number => typeof value === "number");
|
||||
const decimals = placesOf(values);
|
||||
const texts = values.map((value) =>
|
||||
value.toLocaleString("en-US", {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
key: column.id,
|
||||
label: column.label,
|
||||
group,
|
||||
kind: "number",
|
||||
decimals: placesOf(values),
|
||||
decimals,
|
||||
title: [...group, column.label].join(" › "),
|
||||
width: widthOf(longest([...group.slice(1), column.label])),
|
||||
width: widthOf(column.label, ...texts),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -121,9 +128,11 @@ function buildTable(
|
||||
const total: DataRow | null = pipe?.columns.length
|
||||
? { station: L("B08_Quantity_Summary_Total"), ...pipe.total }
|
||||
: null;
|
||||
const labels = stations.map((station) => stationLabel(station, interval));
|
||||
const stationHead = L("B08_Quantity_Summary_Station");
|
||||
const table = createDataTable({
|
||||
columns: [
|
||||
{ key: "station", label: L("B08_Quantity_Summary_Station"), width: 96 },
|
||||
{ key: "station", label: stationHead, width: widthOf(stationHead, ...labels) },
|
||||
...columns.map((column) => ({
|
||||
key: column.id,
|
||||
label: column.name,
|
||||
@@ -135,12 +144,12 @@ function buildTable(
|
||||
...(pipe ? pipeColumns(pipe, comboPath) : []),
|
||||
],
|
||||
// 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값
|
||||
rows: stations.map((station) => ({
|
||||
station: stationLabel(station, interval),
|
||||
rows: stations.map((station, i) => ({
|
||||
station: labels[i],
|
||||
...values.get(stationKey(station)),
|
||||
})),
|
||||
total,
|
||||
onRowClick: (i) => onRowClick(stations[i], stationLabel(stations[i], interval)),
|
||||
onRowClick: (i) => onRowClick(stations[i], labels[i]),
|
||||
frozen: 1,
|
||||
empty: "",
|
||||
caption: L("B08_Quantity_Tab_StructureSummary"),
|
||||
@@ -198,13 +207,14 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
|
||||
}));
|
||||
const things: PlacedItem[] = [...structures.structures, ...placed];
|
||||
const stations = summaryStations(things);
|
||||
// 배관은 서버 집계 열(98) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부)
|
||||
// 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부)
|
||||
const summary = typeof pipe === "string" ? null : pipe;
|
||||
const served = new Set(summary?.types ?? []);
|
||||
const others = [
|
||||
...structures.structures,
|
||||
...placed.filter((point) => (point.facility ?? "pipe") !== "pipe"),
|
||||
...structures.structures.filter((item) => !served.has(item.type_id)),
|
||||
...placed.filter((point) => !served.has(point.facility ?? "pipe")),
|
||||
];
|
||||
const columns = summaryColumns(others, describe);
|
||||
const summary = typeof pipe === "string" ? null : pipe;
|
||||
const parts: HTMLElement[] = [];
|
||||
if (typeof pipe === "string") parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${pipe}`));
|
||||
for (const text of summary?.notes ?? []) parts.push(note(text));
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""B08 구조물 집계표 「횡단배수」 관 밖 구조물 · 열 폭 — PLAN 120.
|
||||
|
||||
견본:
|
||||
300 관 Φ800 L18 + BOX 2.0×2.0(같은 측점 · 키 겹침 막음) 본체 9.6 m · 유입 옛 「있음」 = 날개벽 ·
|
||||
유출 흙막이 메 · 높이 빈칸 → 박스 높이 2.0
|
||||
340 BOX 사용자 입력 2.5×1.5 · 본체 길이 없음(B06 [저장] 전) · 유출 날개벽
|
||||
400 세월교 Φ1000×2련 월류 폭 12 · 유입 날개벽 · 유출 흙막이 찰(높이 빈칸 → 미입력) 3 + 4
|
||||
500 물넘이 포장 월류 폭 8 · H=1.0
|
||||
600 개거 + 노출형 횡단수로 · 700 맹암거(그룹 조합)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B06_Section.B06_Section_Schema import CrossSectionPatch
|
||||
from B08_Quantity import B08_Quantity_Router_Summary as summary
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
needs_node = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
|
||||
)
|
||||
|
||||
|
||||
def _item(key: str) -> dict:
|
||||
return {"layer": "system", "owner": None, "key": key, "name": key}
|
||||
|
||||
|
||||
POINTS = [
|
||||
{"chainage_m": 300.0, "options": {"pipe_diameter_mm": 800}},
|
||||
{
|
||||
"chainage_m": 300.0,
|
||||
"facility": "box_culvert",
|
||||
"options": {
|
||||
"body_width_m": 2,
|
||||
"body_height_m": 2,
|
||||
"wing_in": "있음",
|
||||
"wing_out": "흙막이",
|
||||
"outlet_revet_form": "돌쌓기(메)",
|
||||
"outlet_revet_before_m": 5,
|
||||
"outlet_revet_after_m": 5,
|
||||
},
|
||||
"summary_item": _item("SS000101"),
|
||||
"group_items": {"gboxbody": _item("SS000102")},
|
||||
},
|
||||
{
|
||||
"chainage_m": 340.0,
|
||||
"facility": "box_culvert",
|
||||
"options": {
|
||||
"body_width_m": 2.5,
|
||||
"body_height_m": 1.5,
|
||||
"wing_in": "없음",
|
||||
"wing_out": "날개벽",
|
||||
},
|
||||
},
|
||||
{
|
||||
"chainage_m": 400.0,
|
||||
"facility": "ford_bridge",
|
||||
"options": {
|
||||
"pipe_diameter_mm": 1000,
|
||||
"pipe_count": 2,
|
||||
"ford_width_m": 12,
|
||||
"inlet_type": "날개벽",
|
||||
"wing_in": "있음",
|
||||
"outlet_type": "흙막이",
|
||||
"wing_out": "없음",
|
||||
"outlet_revet_form": "돌쌓기(찰)",
|
||||
"outlet_revet_before_m": 3,
|
||||
"outlet_revet_after_m": 4,
|
||||
},
|
||||
"group_items": {"gford": _item("SS000103"), "gfordin": _item("SS000104")},
|
||||
},
|
||||
{
|
||||
"chainage_m": 500.0,
|
||||
"facility": "ford_pavement",
|
||||
"options": {"ford_width_m": 8, "ford_height_m": 1.0},
|
||||
},
|
||||
]
|
||||
STRUCTURES = [
|
||||
{"structure_id": "s-ditch", "type_id": "open_ditch", "placement": "point", "chainage_m": 600},
|
||||
{
|
||||
"structure_id": "s-exposed",
|
||||
"type_id": "cross_drain_exposed",
|
||||
"placement": "point",
|
||||
"chainage_m": 600,
|
||||
},
|
||||
{
|
||||
"structure_id": "s-ud",
|
||||
"type_id": "cross_underdrain",
|
||||
"placement": "point",
|
||||
"chainage_m": 700,
|
||||
"group_items": {"gcrossud": _item("SS000105")},
|
||||
},
|
||||
]
|
||||
DESIGNS = [
|
||||
{"chainage_m": 300.0, "design": {"pipe_length_m": 18, "box_length_m": 9.6, "inlet_basin": ""}},
|
||||
{"chainage_m": 340.0, "design": {}},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def out(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"
|
||||
)
|
||||
route = root / "B05_Profile/route"
|
||||
route.mkdir(parents=True)
|
||||
(route / "structures.json").write_text(
|
||||
json.dumps({"revision": 1, "structures": STRUCTURES}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
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)
|
||||
return row["values"].get(cid)
|
||||
|
||||
|
||||
def _col(out: dict, cid: str) -> dict:
|
||||
return next(c for c in out["columns"] if c["id"] == cid)
|
||||
|
||||
|
||||
def test_같은_측점_관_박스_따로(out):
|
||||
assert _cell(out, 300, "pipe:len|800") == 18
|
||||
assert _cell(out, 300, "pipe:box|body|2.0×2.0") == 9.6
|
||||
|
||||
|
||||
def test_박스_본체_날개벽_흙막이(out):
|
||||
body = _col(out, "pipe:box|body|2.0×2.0")
|
||||
assert body["group"] == ["BOX암거", "2.0 × 2.0", "본체"] and body["label"] == "m"
|
||||
assert body["combos"] == ["system||SS000101", "system||SS000102"]
|
||||
assert _cell(out, 300, "pipe:box_culvert|wing|inlet") == 1 # 옛 「있음」 = 날개벽
|
||||
assert _cell(out, 300, "pipe:box_culvert|wing|outlet") is None # 유출은 흙막이
|
||||
wing = _col(out, "pipe:box_culvert|wing|inlet")
|
||||
assert wing["group"] == ["BOX암거", "날개벽", "유입"] and wing["label"] == "개소"
|
||||
# 흙막이 빈 높이 = 박스 높이 · 공용 흙막이 열
|
||||
assert _cell(out, 300, "pipe:revet|돌쌓기(메)|2.0") == 10
|
||||
# 사용자 입력 규격 · 본체 길이 없음 = 빈칸 + 알림
|
||||
assert _col(out, "pipe:box|body|2.5×1.5")["group"][1] == "2.5×1.5"
|
||||
assert _cell(out, 340, "pipe:box|body|2.5×1.5") is None
|
||||
assert _cell(out, 340, "pipe:box_culvert|wing|outlet") == 1
|
||||
assert _cell(out, 340, "pipe:box_culvert|wing|inlet") is None
|
||||
assert any("BOX 본체 길이 없음 1곳" in n for n in out["notes"])
|
||||
|
||||
|
||||
def test_세월교_월류폭_개소_날개벽_흙막이_미입력(out):
|
||||
width = _col(out, "pipe:ford|1000|2|width")
|
||||
assert width["group"] == ["세월교", "Φ1000×2련", "월류 폭"] and width["label"] == "m"
|
||||
assert width["combos"] == ["system||SS000103"] # 정의 그룹 밖 잇기 = 저장된 잇기에서
|
||||
assert _cell(out, 400, "pipe:ford|1000|2|width") == 12
|
||||
assert _cell(out, 400, "pipe:ford|1000|2|count") == 1
|
||||
assert _col(out, "pipe:ford|1000|2|count")["label"] == "개소"
|
||||
assert _cell(out, 400, "pipe:ford_bridge|wing|inlet") == 1
|
||||
assert _col(out, "pipe:ford_bridge|wing|inlet")["combos"] == ["system||SS000104"]
|
||||
assert _cell(out, 400, "pipe:ford_bridge|wing|outlet") is None
|
||||
soil = _col(out, "pipe:revet|돌쌓기(찰)|-")
|
||||
assert soil["group"] == ["흙막이", "돌 쌓기(찰)", "미입력"]
|
||||
assert _cell(out, 400, "pipe:revet|돌쌓기(찰)|-") == 7
|
||||
|
||||
|
||||
def test_물넘이_H별_월류폭(out):
|
||||
pave = _col(out, "pipe:pave|1.0")
|
||||
assert pave["group"] == ["물넘이포장", "월류 폭", "H=1.0"] and pave["label"] == "m"
|
||||
assert _cell(out, 500, "pipe:pave|1.0") == 8
|
||||
|
||||
|
||||
def test_개거_노출형_맹암거_개소(out):
|
||||
assert _cell(out, 600, "pipe:open_ditch") == 1
|
||||
assert _cell(out, 600, "pipe:cross_drain_exposed") == 1
|
||||
assert _cell(out, 700, "pipe:cross_underdrain") == 1
|
||||
assert _col(out, "pipe:open_ditch")["group"] == ["개거(겉도랑)"]
|
||||
assert _col(out, "pipe:cross_underdrain")["combos"] == ["system||SS000105"]
|
||||
|
||||
|
||||
def test_차례_계_종류(out):
|
||||
ids = [c["id"] for c in out["columns"]]
|
||||
order = [
|
||||
"pipe:revet|돌쌓기(찰)|-",
|
||||
"pipe:revet|돌쌓기(메)|2.0",
|
||||
"pipe:len|800",
|
||||
"pipe:box|body|2.0×2.0",
|
||||
"pipe:box|body|2.5×1.5",
|
||||
"pipe:box_culvert|wing|inlet",
|
||||
"pipe:ford|1000|2|width",
|
||||
"pipe:ford|1000|2|count",
|
||||
"pipe:ford_bridge|wing|inlet",
|
||||
"pipe:pave|1.0",
|
||||
"pipe:cross_drain_exposed",
|
||||
"pipe:open_ditch",
|
||||
"pipe:cross_underdrain",
|
||||
]
|
||||
assert [ids.index(i) for i in order] == sorted(ids.index(i) for i in order)
|
||||
assert out["total"]["pipe:box_culvert|wing|outlet"] == 1
|
||||
assert set(out["types"]) == {
|
||||
"pipe",
|
||||
"box_culvert",
|
||||
"ford_bridge",
|
||||
"ford_pavement",
|
||||
"cross_drain_exposed",
|
||||
"open_ditch",
|
||||
"cross_underdrain",
|
||||
}
|
||||
|
||||
|
||||
def _width(*texts: str) -> int:
|
||||
source = (ROOT / "B08_Quantity/B08_Quantity_Summary_Data.ts").read_text(encoding="utf-8")
|
||||
source = re.sub(r"^import[^;]*;\n", "", source, flags=re.MULTILINE)
|
||||
script = f"{source}\nconsole.log(widthOf(...{json.dumps(texts, ensure_ascii=False)}));"
|
||||
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
|
||||
return int(result.stdout)
|
||||
|
||||
|
||||
def test_열_폭_맨_아래_단_글만_44_100():
|
||||
assert _width("m") == 44 # 최소
|
||||
assert _width("관매설") == 52 # 한글 12 × 3 + 16
|
||||
assert _width("H=2.0") == 51 # 7 × 5 + 16
|
||||
assert _width("m", "1,234.56") == 72 # 칸 글이 길면 칸 글
|
||||
assert _width("흙막이 돌쌓기(메) 아주 긴 조합 이름") == 100 # 최대 · 머리는 줄바꿈
|
||||
|
||||
|
||||
def test_화면_조합_열_빼기_폭_머리_줄바꿈():
|
||||
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
|
||||
assert "served.has(point.facility" in ui and "served.has(item.type_id)" in ui
|
||||
assert "widthOf(column.label, ...texts)" in ui and "widthOf(column.name)" in ui
|
||||
assert "widthOf(stationHead, ...labels)" in ui
|
||||
css = (ROOT / "B08_Quantity/B08_Quantity_UI_Page.css").read_text(encoding="utf-8")
|
||||
assert '[data-b08-summary="table"] thead th' in css and "white-space: normal" in css
|
||||
|
||||
|
||||
def test_패치가_박스_길이를_받는다():
|
||||
assert CrossSectionPatch(chainage_m=1, box_length_m=9.6).box_length_m == 9.6
|
||||
with pytest.raises(ValueError):
|
||||
CrossSectionPatch(chainage_m=1, box_length_m=-1)
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_서버가_박스_본체_길이를_주인_측점에만():
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT, BUNDLE
|
||||
from common_util.common_util_node_bundle import run_bundle_json
|
||||
|
||||
data = json.loads((FIXTURES / "b06_73_4_body_guard_sections.json").read_text("utf-8"))
|
||||
sections = data["cross_sections"]
|
||||
box_at = next(s for s in sections if s.get("box"))
|
||||
neighbour = next(s for s in sections if s["chainage_m"] == 340)
|
||||
neighbour["box"] = copy.deepcopy(box_at["box"])
|
||||
output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"detail": {"cross_sections": sections}})
|
||||
rows = {round(r["chainage_m"], 3): r for r in output["areas"]}
|
||||
assert rows[352.14]["box_length_m"] > 0
|
||||
assert "box_length_m" not in rows[340]
|
||||
@@ -220,7 +220,8 @@ def test_화면이_서버_칸을_그림():
|
||||
base = Path(__file__).resolve().parents[2]
|
||||
ui = (base / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
|
||||
assert "/quantity/summary" in ui and "pipeColumns(pipe, comboPath)" in ui
|
||||
assert '(point.facility ?? "pipe") !== "pipe"' in ui # 배관 조합 열 대신 서버 열
|
||||
# 배관 · 횡단배수 조합 열 대신 서버 열(120 — 서버가 펼친 종류 `types` 를 뺌)
|
||||
assert '!served.has(point.facility ?? "pipe")' in ui
|
||||
assert "combosAt(things, station)" in ui # 모달은 배관 조합까지 그대로
|
||||
locale = (base / "ui_template/ui_template_locale_b4.ts").read_text(encoding="utf-8")
|
||||
assert 'B08_Quantity_Summary_Total: ["계", "Total"]' in locale
|
||||
|
||||
@@ -43,6 +43,8 @@ _COMPUTED_FIELDS = {
|
||||
# B08 하상경사 원천(110) — 관 경사 · 박스 원지반 경사(%) · 관 길이와 같은 다리.
|
||||
"pipe_slope_pct",
|
||||
"box_ground_slope_pct",
|
||||
# BOX 본체 길이(120) — 같은 다리.
|
||||
"box_length_m",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user