Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_4

This commit is contained in:
2026-10-09 15:16:41 +09:00
49 changed files with 1357 additions and 525 deletions
+4
View File
@@ -96,6 +96,8 @@ export const STATE_REGISTRY = {
scope: "global",
legacy: () => "b06:profile-panel-collapsed",
},
/** B06 횡단 카드 한 줄 카드 수(138-3 · 1~6 · 기본 3). */
"cross-columns": { bucket: "pref", scope: "global" },
"section-panel-height": {
bucket: "pref",
scope: "global",
@@ -103,6 +105,8 @@ export const STATE_REGISTRY = {
},
/* ── ② 설계 초안 ─────────────────────────────────────────────────────── */
/** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */
"structure-notes": { bucket: "draft", scope: "route" },
/** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */
structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` },
/** 3D 램프로 바꾼 측점 상단측(측구 방향). */
@@ -63,6 +63,35 @@ export function wideningRuns(
return runs;
}
/**
* 확폭 면적(㎡ · 138-2 · 표시만) — 확폭(좌 + 우) 측점 사이 사다리꼴 합 Σ(wᵢ + wᵢ₊₁)/2 × Δ.
* 구간 앞뒤 이웃 측점(확폭 0)까지 이어 붙이고 · 노면공 범위 `[start, end]` 밖은 잘라 냄(자른 끝은 직선 보간).
*/
export function wideningArea(
stations: readonly WideningStation[],
run: WideningRun,
range: { start: number; end: number },
): number {
const sorted = [...stations].sort((a, b) => a.chainage_m - b.chainage_m);
const first = sorted.findIndex((s) => Math.abs(s.chainage_m - run.start_m) <= WIDENING_TOL_M);
const last = sorted.findIndex((s) => Math.abs(s.chainage_m - run.end_m) <= WIDENING_TOL_M);
if (first < 0 || last < 0) return 0;
const points = sorted
.slice(Math.max(first - 1, 0), last + 2)
.map((s) => [s.chainage_m, wideningOf(s)] as const);
let area = 0;
for (let i = 0; i + 1 < points.length; i += 1) {
const [x0, w0] = points[i];
const [x1, w1] = points[i + 1];
const a = Math.max(x0, range.start);
const b = Math.min(x1, range.end);
if (b <= a || x1 <= x0) continue;
const at = (x: number): number => w0 + ((w1 - w0) * (x - x0)) / (x1 - x0);
area += ((at(a) + at(b)) / 2) * (b - a);
}
return area;
}
const round = (value: number): number => Number(value.toFixed(2));
/** 저장 글 → 끈 구간 쌍. 틀린 조각은 버림. */
@@ -10,6 +10,7 @@ import {
isRunOff,
offForRuns,
toggleRun,
wideningArea,
wideningRuns,
type WideningRun,
} from "../A00_Common/design/design_structure_widening";
@@ -37,18 +38,24 @@ export function createWideningBox(hooks: {
let off = "";
/** 지금 범위의 구간들 — 횡단 자료 · 범위가 없으면 null(+ 안내 글). */
const runs = (): { list: WideningRun[] | null; note: string } => {
const runs = (): {
list: WideningRun[] | null;
note: string;
area?: (run: WideningRun) => number;
} => {
const sections = pipeSections();
if (!sections?.length) return { list: null, note: "횡단 자료 없음" };
const { start, end } = hooks.range();
if (start === null || end === null || end <= start)
return { list: null, note: "시작 · 종료 측점을 넣으면 보임" };
const list = wideningRuns(sections, start, end);
return { list, note: list.length ? "" : "범위 안 확폭 없음" };
// 확폭 면적(138-2) — 표시만 · 수량엔 안 넣음
const area = (run: WideningRun) => wideningArea(sections, run, { start, end });
return { list, note: list.length ? "" : "범위 안 확폭 없음", area };
};
const paintBody = (): void => {
const { list, note } = runs();
const { list, note, area } = runs();
if (!list?.length) {
const text = document.createElement("span");
text.className = "b05-structure__widening-note";
@@ -68,6 +75,12 @@ export function createWideningBox(hooks: {
const from = stationLabel(run.start_m, step);
const to = stationLabel(run.end_m, step);
button.textContent = `${from === to ? from : `${from} ~ ${to}`} · 확폭 ${Number(run.widening_m.toFixed(2))} m`;
const size = area?.(run);
const areaText = document.createElement("span");
areaText.className = "b05-structure__widening-area";
areaText.textContent =
size !== undefined && Number.isFinite(size) ? `${size.toFixed(2)} ㎡` : "";
button.append(areaText);
button.title = on ? "확폭 포함 — 누르면 규격 폭만" : "규격 폭만 — 누르면 확폭 포함";
button.addEventListener("click", () => {
off = toggleRun(list, off, run);
@@ -608,3 +608,20 @@
.b05-structure__widening [data-widening-run][aria-pressed="true"]::before {
content: "✓ ";
}
/* 확폭 면적(138-2) — 단추 안 오른쪽 끝 · 끈 구간 회색 */
.b05-structure__widening [data-widening-run] {
display: flex;
align-items: center;
column-gap: 4px;
}
.b05-structure__widening-area {
margin-left: auto;
padding-left: var(--spacing-8, 8px);
white-space: nowrap;
}
.b05-structure__widening [aria-pressed="false"] .b05-structure__widening-area {
color: var(--color-text-secondary);
}
+26
View File
@@ -285,3 +285,29 @@ export async function previewCrossDesigns(
},
);
}
/** B07 횡단 수량표 한 장(138-4 · 서버 `cross_quantity_sheet`) — 값 · 글 · 칸 배치. */
export interface CrossQuantitySheet {
quantity_table: Record<string, number | null>;
/** B07 표에 찍히는 글 그대로(값 없음 = 「-」). */
text: Record<string, string>;
layout: {
header: Array<[string, string]>;
left: Array<[string | null, string | null, string | null]>;
middle: Array<[string | null, string, string]>;
right: Array<[string | null, string | null]>;
};
}
/** 캐시 설계로 B07 횡단 수량표를 받는다 — 표시 전용(정본을 안 씀). */
export async function fetchCrossQuantityTable(
projectId: string,
routeId: number,
chainageM: number,
design: CrossDesign | null | undefined,
): Promise<CrossQuantitySheet> {
return requestJson<CrossQuantitySheet>(
`/projects/${projectId}/sections/${routeId}/cross-quantity-table`,
{ method: "POST", body: JSON.stringify({ chainage_m: chainageM, design: design ?? null }) },
);
}
+4
View File
@@ -464,6 +464,8 @@ export interface CrossDesign {
pavement_thickness_m?: number;
/** B05 법정 경사 분석의 포장 제안 여부(사용자 토글과 무관하게 유지). */
pavement_suggested?: boolean;
/** 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸). */
structure_note?: string;
/** 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. */
road_edges: {
left: { offset_m: number; elevation_m: number };
@@ -605,4 +607,6 @@ export interface CrossSectionPatch {
fill_area_m2?: number;
cut_soil_area_m2?: number;
cut_rock_area_m2?: number;
/** 측점 구조물 글(138-4) — 빈 글 = 지움. */
structure_note?: string;
}
+11 -1
View File
@@ -87,6 +87,8 @@ export const USER_TOUCHED_KEYS = [
"berm",
// 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다.
"cut_slope_ratio_user",
// 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
"structure_note",
] as const;
/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */
@@ -108,6 +110,14 @@ const PRESERVED_KEYS = [
...SPOIL_KEYS,
] as const;
/**
* 포장 = 노면공 · 물넘이 범위 안만(138-1) — 측점에 얹힌 제원(`pavement` · `ford_pavement`)이 곧 범위다.
* 서버 짝 = `B06_Section_Router_Design.paved_at` · 저장값 · 카드 선택은 쓰지 않는다.
*/
export function pavedBySpecs(section: Pick<CrossSection, "pavement" | "ford_pavement">): boolean {
return Boolean(section.pavement?.length) || Boolean(section.ford_pavement);
}
function preserveUserFields(
next: NonNullable<CrossSection["design"]>,
previous: CrossSection["design"],
@@ -293,7 +303,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
ditchType:
choice?.ditch_type ??
(typeof design.ditch_type === "string" ? design.ditch_type : "standard"),
paved: choice?.paved ?? Boolean(design.paved),
paved: pavedBySpecs(section),
standard,
berm: bermAt(section.chainage_m),
rockBoundaryOffsetM,
+3 -1
View File
@@ -44,6 +44,8 @@ from B06_Section.B06_Section_Router_Design import (
USER_TOUCHED_KEYS,
ford_drop_at,
ford_surface_drops,
paved_at,
pavement_ranges,
stored_berm,
stored_cut_slope,
)
@@ -640,7 +642,7 @@ async def compute_cross_section_design(
section_mode=request.section_mode,
ditch_side=request.ditch_side,
ditch_type=request.ditch_type,
paved=request.paved,
paved=paved_at(request.chainage_m, pavement_ranges(project_root)),
standard=request.standard_cross_section,
rock_boundary_offset_m=request.rock_boundary_offset_m,
two_stage_slope=request.two_stage_slope,
@@ -0,0 +1,94 @@
"""B06 횡단 [상세] 모달의 **B07 횡단 수량표 값** 창구(138-4).
값은 B07 횡단도가 쓰는 `_quantity_table` 한 벌을 **브라우저 캐시 설계**로 돌린 것이다 —
화면이 TS 로 다시 짜지 않는다(CLAUDE.md 5장 ③ 서버 단독). 칸 배치도 B07 표 정의를 그대로
실어 보내 화면은 그리기만 한다. **표시 전용** — 정본을 쓰지 않는다. 모달을 열 때 부른다.
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B06_Section.B06_Section_Repository import get_longitudinal_section
from B06_Section.B06_Section_Router_Design import read_cross_design_inputs
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import _format
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
_CROSS_HEADER_KEYS,
_CROSS_LEFT_ROWS,
_CROSS_MIDDLE_ROWS,
_CROSS_RIGHT_ROWS,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support import _quantity_table
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
class CrossQuantityTableRequest(BaseModel):
chainage_m: float = Field(..., ge=0)
# 브라우저 캐시의 그 측점 설계(조작 반영본) — 없으면 원본 칸만.
design: dict[str, Any] | None = None
def cross_quantity_sheet(source: dict[str, Any], design: dict[str, Any] | None) -> dict[str, Any]:
"""B07 횡단 수량표 한 장 — 값(숫자) · 글(B07 표와 같은 꼴) · 칸 배치."""
table = _quantity_table(source, design)
text = {
key: _format(value) if isinstance(value, (int, float)) else "-"
for key, value in table.items()
}
return {
"quantity_table": table,
"text": text,
"layout": {
"header": [list(row) for row in _CROSS_HEADER_KEYS],
"left": [list(row) for row in _CROSS_LEFT_ROWS],
"middle": [list(row) for row in _CROSS_MIDDLE_ROWS],
"right": [list(row) for row in _CROSS_RIGHT_ROWS],
},
}
@router.post("/{project_id}/sections/{route_id}/cross-quantity-table", response_model=None)
async def cross_quantity_table(
project_id: UUID, route_id: int, request: CrossQuantityTableRequest
) -> JSONResponse:
pool = get_db_pool()
async with pool.acquire() as connection:
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
stored_path = await get_project_storage_relative_path(connection, project_id)
if not longitudinal or not stored_path:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "종횡단 상세 결과가 없습니다."},
)
try:
*_, source = await asyncio.to_thread(
read_cross_design_inputs,
Path(resolve_stored_project_path(stored_path)),
str(longitudinal["longitudinal_file_path"]),
request.chainage_m,
)
sheet = cross_quantity_sheet(source, request.design)
except (FileNotFoundError, ValueError) as error:
return JSONResponse(status_code=404, content={"status": "error", "message": str(error)})
except Exception:
logger.exception(
"횡단 수량표 계산 실패: project_id=%s chainage=%s", project_id, request.chainage_m
)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "횡단 수량표를 만들지 못했습니다."},
)
return JSONResponse(content={"status": "success", **sheet})
+21 -15
View File
@@ -110,12 +110,17 @@ def ford_drop_at(chainage_m: float, drops: list[tuple[float, float]]) -> float:
return 0.0
def paved_at(chainage_m: float, ranges: list[tuple[float, float]], stored: Any = None) -> bool:
"""이 측점을 포장으로 볼 것인가 — 구간 안이면 강제, 밖이면 사용자 저장값(기본 비포장)."""
for start, end in ranges:
if start - 1e-9 <= chainage_m <= end + 1e-9:
return True
return bool(stored) if isinstance(stored, bool) else False
# 범위 끝 허용오차(m) — 브라우저 짝(`common_util_structure_pavement` · `_culvert_sets`)과 같은 값.
_PAVED_EDGE_TOLERANCE_M = 0.02
def paved_at(chainage_m: float, ranges: list[tuple[float, float]]) -> bool:
"""이 측점을 포장으로 볼 것인가 — 노면공 · 물넘이 범위 안만 포장, 밖은 비포장(저장값 무시).
브라우저 짝 = `B06_Section_Cross_Refresh.ts` `pavedBySpecs`(같은 규칙 · 138-1).
"""
tol = _PAVED_EDGE_TOLERANCE_M
return any(start - tol <= chainage_m <= end + tol for start, end in ranges)
# 사용자 조작값 — 다시 계산해도 그대로 승계한다. **이 목록이 서버의 유일한 한 벌이다.**
@@ -143,6 +148,8 @@ USER_TOUCHED_KEYS = (
# 측점별 암 절토 경사 — 표준 횡단면 설정을 바꿔도 개별로 고친 측점은 그대로 둔다
# (2026-09-07 사용자 확정: 「사용자가 기본값을 사용하지 않는 값들은 변경되면 안됨」).
"cut_slope_ratio_user",
# 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
"structure_note",
)
# 노선 [확인] 체인이 옛 노선에서 누가거리로 넘기는 **사용자 입력**(PLAN 74-3) — 계획고 · 면적 ·
@@ -152,7 +159,6 @@ CARRY_INPUT_KEYS = (
"ground_type",
"ditch_type",
"ditch_choice",
"paved",
"two_stage_slope",
"rock_boundary_offset_m",
"cut_slope_ratio_user",
@@ -178,23 +184,23 @@ def enforce_pavement_ranges(
project_root: Path,
standard: dict[str, Any] | None = None,
) -> int:
"""포장 구간·물넘이 범위 안 측점을 포장으로 맞춘다 — 저장분이 비포장이어도 그렇다.
"""포장 = 노면공 · 물넘이 범위 안만 — 저장분과 다르면 켬 · 끔 양쪽으로 맞춘다(138-1).
사용자가 구간을 지정하면 그 안 횡단도에 **자동 반영**된다(2026-08-28 사용자 확정).
범위 밖 옛 `paved=true`(지운 노면공 · 옛 카드 단추)는 비포장으로 되돌린다.
포장 여부는 횡단경사·포장층을 바꾸므로 플래그만 갈아 끼우지 않고 다시 계산한다.
사용자 조작값(반폭·구조물 조정)은 그대로 승계한다.
"""
ranges = pavement_ranges(project_root)
if not ranges:
return 0
ford_drops = ford_surface_drops(project_root)
changed = 0
for section in cross_sections:
design = section.get("design")
if not isinstance(design, dict) or design.get("paved"):
if not isinstance(design, dict):
continue
chainage = float(section.get("chainage_m", 0.0))
if not paved_at(chainage, ranges):
wanted = paved_at(chainage, ranges)
if bool(design.get("paved")) == wanted:
continue
try:
recomputed = compute_cross_design(
@@ -204,7 +210,7 @@ def enforce_pavement_ranges(
section_mode=str(design.get("section_mode") or "left_cut"),
ditch_side=design.get("ditch_side"),
ditch_type=str(design.get("ditch_type") or "standard"),
paved=True,
paved=wanted,
standard=standard,
rock_boundary_offset_m=design.get(
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
@@ -449,7 +455,7 @@ def recompute_designs_for_alignment(
section_mode=str(stored.get("section_mode") or modes.get(key, "left_cut")),
ditch_side=stored.get("ditch_side"),
ditch_type=str(stored.get("ditch_type") or "standard"),
paved=paved_at(chainage, paved_ranges, stored.get("paved")),
paved=paved_at(chainage, paved_ranges),
standard=standard,
rock_boundary_offset_m=session_offsets.get(
key,
@@ -527,7 +533,7 @@ def compute_default_designs(
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
ditch_type=str(user.get("ditch_type") or "standard"),
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28).
paved=paved_at(chainage_m, paved_ranges, user.get("paved")),
paved=paved_at(chainage_m, paved_ranges),
standard=standard,
rock_boundary_offset_m=(
STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M if rock_offset is None else rock_offset
+2
View File
@@ -192,6 +192,8 @@ class CrossSectionPatch(BaseModel):
box_length_m: float | None = Field(default=None, ge=0)
# B08 자재 집계표 세월교 관 한 련 길이(131) — 그린 관 길이 1 m 올림(m) · 같은 길.
ford_pipe_length_m: float | None = Field(default=None, ge=0)
# 측점 구조물 글(138-4 · 횡단 [상세] 모달) — 빈 글 = 지움.
structure_note: str | None = Field(default=None, max_length=2000)
class SectionConfirmRequest(BaseModel):
+14 -18
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* B06_Section_UI_Cross_Design.ts
* 측점 표준횡단 설계 지정 컨트롤(지반유형·단면유형·측구위치·측구형식·포장)과
* 측점 표준횡단 설계 지정 컨트롤(지반유형·단면유형·측구위치·측구형식)과
* 설계선·암 경계선·포장층 오버레이.
*
* 카드 헤더 아래에 세그먼트 버튼을 배치하고, 선택이 바뀌면 onChange로 계산을
@@ -27,6 +27,7 @@ export {
appendRockBoundaryOverlay,
} from "./B06_Section_UI_Cross_Design_Surface";
export { appendCrossDesignOverlay } from "./B06_Section_UI_Cross_Design_Line";
import { openCrossDetail, refreshCrossDetail } from "./B06_Section_UI_Cross_Detail";
/** 넘침 정리 한 장 몫 — 쓰기·읽기·쓰기 세 토막으로 갈라 두어 프레임 단위로 묶는다. */
interface ReflowCard {
@@ -439,25 +440,13 @@ export function buildDesignControls(
!rockCut,
L("B06_Design_Disabled_RockCut"),
);
// 포장 토글: 라벨 삭제, 버튼명 활성="포장"/비활성="비포장"(컬러 유지) — E-7.
const paved = toggle("", state.paved, L("B06_Design_Paved_On"), L("B06_Design_Paved_Off"), () => {
state.paved = !state.paved;
emit();
});
// B05 법정 경사 분석이 포장을 제안한 측점은 근거 문구를 배지·툴팁으로 표기한다.
if (design?.pavement_suggested) {
paved.button.title = L("B06_Design_Paved_Suggested");
const badge = document.createElement("span");
badge.className = "b06-design__paved-badge";
badge.textContent = "⚠";
badge.title = L("B06_Design_Paved_Suggested");
paved.wrap.append(badge);
}
// 포장 단추는 없다(138-1) — 포장 = 노면공 · 물넘이 범위만(`pavedBySpecs`) · 「포장 권장」은 알림 줄.
// `state.paved` 는 저장값 그대로 실어 보낸다(재계산이 범위로 다시 정함).
// 오버플로 드롭다운(N-2-2): 컨트롤이 카드 폭에서 한 행을 넘치면 뒤쪽부터 우측 "⋯"
// 드롭다운(세로 배치)으로 옮겨 다음 행으로 밀리는 것을 막는다. 경사 방향은 항상 인라인,
// 이동 우선순위(뒤에서부터): 포장 → 복합경사 → 측구형식 → 측구 토글.
const moveable = [ditchToggle.wrap, ditchTypeSeg, twoStage.wrap, paved.wrap];
// 이동 우선순위(뒤에서부터): 복합경사 → 측구형식 → 측구 토글.
const moveable = [ditchToggle.wrap, ditchTypeSeg, twoStage.wrap];
const more = document.createElement("details");
more.className = "b06-design__more";
const moreSummary = document.createElement("summary");
@@ -467,7 +456,14 @@ export function buildDesignControls(
const morePanel = document.createElement("div");
morePanel.className = "b06-design__more-panel";
more.append(moreSummary, morePanel);
bar.append(slopeDirSeg, ...moveable, more);
// [상세](138-4) — 맨 오른쪽 · 넘침 정리(moveable) 밖이라 늘 보임
const detail = document.createElement("button");
detail.type = "button";
detail.className = "b06-design__btn b06-design__detail";
detail.textContent = L("B06_Design_Detail");
detail.addEventListener("click", () => openCrossDetail(section));
bar.append(slopeDirSeg, ...moveable, more, detail);
refreshCrossDetail(section);
// 넘침 정리는 **세 토막**으로 나눈다 — 쓰기(reset) → 읽기(measure) → 쓰기(apply).
// 카드 안에서 한 장씩 하면 쓰기 뒤 읽기가 카드 수만큼 반복돼 브라우저가 레이아웃을
+260
View File
@@ -0,0 +1,260 @@
/* =============================================================================
* B06_Section_UI_Cross_Detail.ts
* 횡단 카드 측구 줄 맨 오른쪽 [상세] → 모달(138-4).
*
* 맨 위 = 측점 구조물 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄) ·
* 가운데 = 카드 그림 복제(어두운 바탕 · CAD 꼴) · 아래 = B07 횡단 수량표(값 · 칸 배치 모두
* 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름).
* 막은 투명(누름 통과) · 자리는 그 카드를 안 가리는 넓은 쪽(좌/우 → 위/아래).
* 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤.
* ========================================================================== */
import "./B06_Section_UI_Style_Cross_Detail.css";
import { openModal } from "@ui/ui_template_modal";
import { readState, writeState } from "../A00_Common/b_page_state";
import { fetchCrossQuantityTable } from "./B06_Section_Api_Fetch";
import type {
CrossQuantitySheet,
CrossSection,
SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import { inferStationInterval, stationLabel } from "./B06_Section_UI_Section_Common";
/** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */
export interface CrossDetailHost {
projectId: string;
routeId: () => number | null;
detail: () => SectionDetailResponse | null;
}
let host: CrossDetailHost | null = null;
export function setCrossDetailHost(next: CrossDetailHost): void {
host = next;
}
/* ── 구조물 글 세션 초안(측점키 0.01 m → 글) ─────────────────────────────── */
const noteKey = (chainageM: number): string => chainageM.toFixed(2);
/** [저장]이 patch 로 싣는 글 — 측점(m) → 글(빈 글 = 지움). */
export function readStructureNotes(projectId: string, routeId: number): Map<number, string> {
const raw = readState<Record<string, string>>("structure-notes", projectId, routeId) ?? {};
return new Map(Object.entries(raw).map(([key, text]) => [Number(key), String(text)]));
}
function noteOf(section: CrossSection): string {
const routeId = host?.routeId();
if (host && routeId !== null && routeId !== undefined) {
const draft = readState<Record<string, string>>("structure-notes", host.projectId, routeId);
const text = draft?.[noteKey(section.chainage_m)];
if (text !== undefined) return text;
}
return section.design?.structure_note ?? "";
}
function writeNote(section: CrossSection, text: string): void {
// 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음
if (section.design) section.design.structure_note = text;
const routeId = host?.routeId();
if (!host || routeId === null || routeId === undefined) return;
const all = readState<Record<string, string>>("structure-notes", host.projectId, routeId) ?? {};
all[noteKey(section.chainage_m)] = text;
writeState("structure-notes", all, host.projectId, routeId);
}
/* ── 자리 ───────────────────────────────────────────────────────────── */
const GAP_PX = 12;
const MIN_WIDTH_PX = 360;
const MAX_WIDTH_PX = 720;
/** 카드를 안 가리는 넓은 쪽에 붙임 — 좌우가 좁으면 위아래. */
function place(dialog: HTMLElement, card: HTMLElement | null): void {
const vw = window.innerWidth;
const vh = window.innerHeight;
const rect = card?.getBoundingClientRect();
const style = dialog.style;
style.position = "fixed";
style.margin = "0";
if (!rect) {
style.width = `${Math.min(MAX_WIDTH_PX, vw - GAP_PX * 2)}px`;
style.left = `${(vw - Math.min(MAX_WIDTH_PX, vw - GAP_PX * 2)) / 2}px`;
style.top = `${GAP_PX}px`;
style.maxHeight = `${vh - GAP_PX * 2}px`;
return;
}
const left = rect.left;
const right = vw - rect.right;
if (Math.max(left, right) >= MIN_WIDTH_PX + GAP_PX * 2) {
const width = Math.min(MAX_WIDTH_PX, Math.max(left, right) - GAP_PX * 2);
const top = Math.min(Math.max(rect.top, GAP_PX), vh - GAP_PX - 240);
style.width = `${width}px`;
style.left = `${right >= left ? rect.right + GAP_PX : rect.left - GAP_PX - width}px`;
style.top = `${Math.max(top, GAP_PX)}px`;
style.maxHeight = `${vh - Math.max(top, GAP_PX) - GAP_PX}px`;
return;
}
const width = Math.min(MAX_WIDTH_PX, vw - GAP_PX * 2);
style.width = `${width}px`;
style.left = `${Math.min(Math.max(rect.left, GAP_PX), vw - GAP_PX - width)}px`;
const below = vh - rect.bottom;
if (below >= rect.top) {
style.top = `${rect.bottom + GAP_PX}px`;
style.maxHeight = `${Math.max(below - GAP_PX * 2, 160)}px`;
} else {
style.top = `${GAP_PX}px`;
style.maxHeight = `${Math.max(rect.top - GAP_PX * 2, 160)}px`;
}
}
/* ── 표(B07 칸 배치 그대로) ─────────────────────────────────────────────── */
function cell(tag: "th" | "td", text: string, span?: { col?: number; row?: number }): HTMLElement {
const element = document.createElement(tag);
element.textContent = text;
if (span?.col) element.setAttribute("colspan", String(span.col));
if (span?.row) element.setAttribute("rowspan", String(span.row));
return element;
}
/** B07 `_cross_table_entities` 와 같은 꼴 — 제목 · 머리 넷 쌍 · 본문 6행 × (좌 · 중 · 우). */
export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HTMLTableElement {
const { layout, text } = sheet;
const value = (key: string | null): string => (key ? (text[key] ?? "-") : "");
const table = document.createElement("table");
table.className = "b06-detail__qtable";
const titleRow = table.insertRow();
titleRow.append(cell("th", title, { col: 8 }));
const headRow = table.insertRow();
for (const [label, key] of layout.header)
headRow.append(cell("th", label), cell("td", value(key)));
layout.left.forEach(([groupL, subL, keyL], index) => {
const row = table.insertRow();
if (groupL !== null && subL !== null) row.append(cell("th", groupL, { row: 2 }));
else if (groupL !== null) row.append(cell("th", groupL, { col: 2 }));
if (subL !== null) row.append(cell("th", subL));
row.append(cell("td", value(keyL)));
const [groupM, subM, keyM] = layout.middle[index];
if (groupM !== null) row.append(cell("th", groupM, { row: 2 }));
row.append(cell("th", subM), cell("td", value(keyM)));
const [labelR, keyR] = layout.right[index];
row.append(cell("th", labelR ?? ""), cell("td", value(keyR)));
});
return table;
}
/* ── 모달 ───────────────────────────────────────────────────────────── */
interface OpenDetail {
stationId: string;
section: CrossSection;
paint: () => void;
loadTable: () => void;
close: () => void;
}
let current: OpenDetail | null = null;
let tableTimer = 0;
const cardOf = (section: CrossSection): HTMLElement | null =>
document.getElementById(`cross-${section.station_id}`);
function titleOf(section: CrossSection): string {
const stations = host?.detail()?.longitudinal.stations ?? [];
return stationLabel(section.chainage_m, inferStationInterval(stations));
}
/** [상세] — 이미 열린 것은 닫고 이 측점으로 엶. */
export function openCrossDetail(section: CrossSection): void {
current?.close();
const title = titleOf(section);
let tableSeq = 0;
openModal({
title: `${title} 상세`,
closeLabel: "닫기",
dialogClass: "b06-detail",
backdropClass: "b06-detail__backdrop",
mount(body, close) {
const dialog = body.parentElement as HTMLElement;
const note = document.createElement("textarea");
note.className = "ui-input b06-detail__note";
note.rows = 3;
note.placeholder = "구조물 메모 — 이 측점에 남길 글";
note.value = noteOf(section);
note.addEventListener("input", () => writeNote(state.section, note.value));
const cad = document.createElement("div");
cad.className = "b06-detail__cad";
const tableBox = document.createElement("div");
tableBox.className = "b06-detail__table";
body.append(note, cad, tableBox);
const state: OpenDetail = {
stationId: section.station_id,
section,
paint() {
const card = cardOf(state.section);
const svg = card?.querySelector("svg.b06-section__chart");
cad.replaceChildren(svg ? svg.cloneNode(true) : document.createTextNode("그림 없음"));
place(dialog, card);
},
loadTable() {
const projectId = host?.projectId;
const routeId = host?.routeId();
if (!projectId || routeId === null || routeId === undefined) {
tableBox.textContent = "노선 정보 없음";
return;
}
const seq = ++tableSeq;
tableBox.classList.add("is-loading");
fetchCrossQuantityTable(
projectId,
routeId,
state.section.chainage_m,
state.section.design,
)
.then((sheet) => {
if (seq !== tableSeq || current !== state) return;
tableBox.replaceChildren(buildQuantityTable(sheet, title));
})
.catch((error: unknown) => {
if (seq !== tableSeq || current !== state) return;
tableBox.textContent = `수량표를 못 받음 — ${error instanceof Error ? error.message : error}`;
})
.finally(() => seq === tableSeq && tableBox.classList.remove("is-loading"));
},
close() {
forget();
close();
},
};
current = state;
tableBox.textContent = "수량표 불러오는 중…";
state.paint();
state.loadTable();
},
// 닫기 단추 · Esc 도 같은 정리를 거침
beforeClose: () => {
forget();
return true;
},
});
}
/** 열린 모달 기억을 지움(닫힐 때). */
function forget(): void {
window.clearTimeout(tableTimer);
current = null;
}
/**
* 카드를 다시 그릴 때마다 부름(`buildDesignControls`) — 그 측점 모달이 열려 있으면 새 카드로
* 그림을 다시 뜨고 표는 조작이 멈춘 뒤 한 번 다시 받음(최신 응답만).
*/
export function refreshCrossDetail(section: CrossSection): void {
const open = current;
if (!open || open.stationId !== section.station_id) return;
open.section = section;
requestAnimationFrame(() => current === open && open.paint());
window.clearTimeout(tableTimer);
tableTimer = window.setTimeout(() => current === open && open.loadTable(), 400);
}
@@ -4,6 +4,8 @@
* (2026-09-14 브레인 승인 (나)). 판정은 `_Cross_FillSlope_Warn`, 여기는 그리기만.
* 측점을 누르면 그 카드로 간다. 경고만 — 구조물을 세우거나 값을 바꾸지 않는다.
* ⭐ 2026-09-15 — 그 밑에 사면길이 3m 초과 **소단 검토 정보 한 줄**(경고 모양 아님 · 브레인 ③).
* ⭐ 138-1 — 「⚠ 포장 권장 · n측점」 줄(B05 법정 경사 제안 · 포장 범위 밖 측점 · 옛 카드 배지 자리).
* ⭐ 138-3 — 줄 전체가 카드 칸 위에 고정(sticky) · 알림 없어도 줄 유지(오른쪽 끝 열 수 칸은 부르는 쪽이 붙임).
* ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch";
@@ -15,6 +17,7 @@ import {
type FillSlopeWarning,
} from "./B06_Section_UI_Cross_FillSlope_Warn";
import { L, stationLabel } from "./B06_Section_UI_Section_Common";
import { pavedBySpecs } from "./B06_Section_Cross_Refresh";
export interface FillSlopeNotice {
root: HTMLElement;
@@ -46,7 +49,8 @@ function noticeBlock(className: string): {
const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right");
return `${label} ${open ? "≥" : ""}${lengthM.toFixed(2)}m`;
});
button.textContent = `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`;
button.textContent =
`${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`.trim();
button.dataset.stationId = section.station_id;
return button;
}),
@@ -57,9 +61,15 @@ function noticeBlock(className: string): {
export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice {
const root = document.createElement("div");
root.className = "b06-section__notice-bar";
const blocks = document.createElement("div");
blocks.className = "b06-section__notice-blocks";
root.append(blocks);
const warn = noticeBlock("b06-section__notice");
const info = noticeBlock("b06-section__notice b06-section__notice--info");
root.append(warn.root, info.root);
const pave = noticeBlock("b06-section__notice");
pave.root.title = L("B06_Design_Paved_Suggested");
blocks.append(warn.root, pave.root, info.root);
root.addEventListener("click", (event) => {
const id = (event.target as HTMLElement).closest("button")?.dataset.stationId;
if (id) onPick(id);
@@ -73,6 +83,13 @@ export function createFillSlopeNotice(onPick: (stationId: string) => void): Fill
fillSlopeWarnings(sections, fillSlopeLengths),
stationInterval,
);
pave.fill(
`⚠ ${L("B06_Notice_PaveSuggested")}`,
sections
.filter((section) => section.design?.pavement_suggested && !pavedBySpecs(section))
.map((section) => ({ section, sides: [] })),
stationInterval,
);
info.fill(
`ⓘ ${BERM_INFO_TEXT}`,
bermReviewStations(sections, slopeRunLengths),
@@ -63,6 +63,20 @@ export function crossPlotBase(
return { sourceSamples, minOffset, maxOffset, elevationMid, pixelsPerMeter, naturalHeight };
}
/**
* 같은 기하를 다른 카드 폭으로 그렸을 때 자연 높이(바닥 적용 전 · 다시 계산 안 함 · 138-3).
* 그림 몫은 폭(좌우 여백 뺀)에 비례 · 위아래 여백은 그대로.
*/
export function naturalHeightAtWidth(
base: CrossPlotBase,
fromWidthPx: number,
toWidthPx: number,
): number {
const fixed = CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX;
const inner = (width: number): number => Math.max(width - CROSS_PAD.left - CROSS_PAD.right, 1e-6);
return (base.naturalHeight - fixed) * (inner(toWidthPx) / inner(fromWidthPx)) + fixed;
}
/** 재 둔 기하에 행 높이만 얹는다 — 다시 계산하지 않는다. */
export function crossPlotFromBase(base: CrossPlotBase, forcedHeightPx?: number): CrossPlotMetrics {
const heightPx = forcedHeightPx ?? Math.max(base.naturalHeight, CROSS_HEIGHT);
@@ -23,6 +23,7 @@ import { L } from "./B06_Section_UI_Page_Common";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View";
import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures";
import { setCrossDetailHost } from "./B06_Section_UI_Cross_Detail";
export interface DesignSyncContext {
projectId: string | null;
@@ -42,6 +43,8 @@ export interface DesignSyncController {
export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
const { projectId } = ctx;
// 횡단 [상세] 모달(138-4)이 쓰는 프로젝트 · 노선 · 상세
if (projectId) setCrossDetailHost({ projectId, routeId: ctx.routeId, detail: ctx.detail });
/**
* 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응),
@@ -29,6 +29,7 @@ import {
structureAreaRows,
} from "./B06_Section_Structure_Layouts";
import { crossDesignChoices } from "./B06_Section_Cross_Design_Session";
import { readStructureNotes } from "./B06_Section_UI_Cross_Detail";
import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
@@ -386,6 +387,12 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
// 자동으로 되돌아가지 않으므로(켬/끔 둘 뿐) 지금은 손해가 없다(2026-09-09).
if (typeof choice.ditch_choice === "boolean") patch.ditch_choice = choice.ditch_choice;
});
// 측점 구조물 글(138-4 · [상세] 모달) — 세션 초안 그대로(빈 글 = 지움)
const routeId = ctx.routeId();
if (ctx.projectId && routeId !== null)
readStructureNotes(ctx.projectId, routeId).forEach((note, chainage) => {
patchFor(chainage).structure_note = note;
});
if (detail) {
// 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산한다
// (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」).
+32 -36
View File
@@ -1,47 +1,43 @@
/* =============================================================================
* B06_Section_UI_Section_Columns.ts
* 횡단 카드 한 줄 카드 수 고르기 — 보기 칸 높이에 첫 줄 + 둘째 줄 절반이 들어가는
* 가장 적은 카드 수. 카드 폭은 최소~최대 폭 안(그림은 축척 그대로 · 폭만 바뀜).
* 횡단 카드 열 수 · 폭 · 높이(138-3) — 열 수 = 사용자 값(알림 줄 오른쪽 끝 · 1~6 · 기본 3).
* 카드 폭 = (칸 폭 − 간격) ÷ 열 수 · 그래프 높이 = 폭 × (3열 기준 높이 ÷ 3열 폭) — 열 수가
* 바뀌어도 그림 비율 그대로(글자 크기는 그대로).
* import 없음 — 시험이 이 파일만 옮겨 돌림(`test_138_cross_columns.py`).
* ========================================================================== */
import {
CROSS_CARD_CHROME,
CROSS_GRID_GAP,
CROSS_GRID_MAX_WIDTH,
CROSS_GRID_MIN_WIDTH,
} from "./B06_Section_UI_Section_Common";
export const CROSS_COLUMNS_MIN = 1;
export const CROSS_COLUMNS_MAX = 6;
export const CROSS_COLUMNS_DEFAULT = 3;
/** 높이 비율의 기준 열 수. */
export const CROSS_COLUMNS_BASE = 3;
export interface CrossColumns {
columns: number;
cardWidth: number;
/** 저장값 → 열 수(정수 · 1~6 · 못 읽으면 기본 3). */
export function clampCrossColumns(value: unknown): number {
const number = typeof value === "string" && value.trim() === "" ? NaN : Number(value);
if (!Number.isFinite(number)) return CROSS_COLUMNS_DEFAULT;
return Math.min(CROSS_COLUMNS_MAX, Math.max(CROSS_COLUMNS_MIN, Math.round(number)));
}
const widthFor = (columns: number, viewWidth: number): number =>
Math.min(CROSS_GRID_MAX_WIDTH, (viewWidth - (columns - 1) * CROSS_GRID_GAP) / columns);
/** 카드 폭(px) = (칸 폭 − 간격 × (열 수 − 1)) ÷ 열 수. */
export function crossCardWidth(viewWidth: number, columns: number, gap: number): number {
return Math.max(0, (viewWidth - gap * (columns - 1)) / columns);
}
/**
* `heightsFor(cardWidth, count)` — 그 폭일 때 앞 `count` 장의 카드 자연 높이.
* `viewHeight` 가 0 이하(숨김 · 아직 안 잰 칸)면 가장 큰 카드 쪽을 고른다.
* 카드마다 강제 높이(px) — `baseHeights[i]` = 그 카드를 3열 폭(`baseWidth`)으로 그렸을 때 높이.
* 지금 폭으로 비례해 늘리고 · 같은 줄(열 수 단위) 최댓값으로 맞춤.
*/
export function pickCrossColumns(
viewWidth: number,
viewHeight: number,
heightsFor: (cardWidth: number, count: number) => number[],
): CrossColumns {
const unit = CROSS_GRID_MIN_WIDTH + CROSS_GRID_GAP;
const most = Math.max(1, Math.floor((viewWidth + CROSS_GRID_GAP) / unit));
const fewest = Math.min(
most,
Math.max(1, Math.ceil((viewWidth + CROSS_GRID_GAP) / (CROSS_GRID_MAX_WIDTH + CROSS_GRID_GAP))),
);
for (let columns = fewest; columns < most; columns += 1) {
const cardWidth = widthFor(columns, viewWidth);
if (viewHeight <= 0) return { columns, cardWidth };
const heights = heightsFor(cardWidth, columns * 2);
const first = Math.max(0, ...heights.slice(0, columns)) + CROSS_CARD_CHROME;
const second = Math.max(0, ...heights.slice(columns)) + CROSS_CARD_CHROME;
const needed = heights.length > columns ? first + CROSS_GRID_GAP + second / 2 : first;
if (needed <= viewHeight) return { columns, cardWidth };
}
return { columns: most, cardWidth: widthFor(most, viewWidth) };
export function crossRowHeights(
baseHeights: readonly number[],
baseWidth: number,
cardWidth: number,
columns: number,
): number[] {
const scale = baseWidth > 0 ? cardWidth / baseWidth : 1;
const heights = baseHeights.map((height) => height * scale);
return heights.map((_, index) => {
const start = index - (index % columns);
return Math.max(...heights.slice(start, start + columns));
});
}
@@ -35,11 +35,6 @@ export const LONG_WIDTH = 1200;
export const LONG_HEIGHT = 220;
export const CROSS_WIDTH = 560;
export const CROSS_HEIGHT = 250;
// 카드 폭 범위 — 한 줄 카드 수는 보기 칸 높이로 정한다(`_Section_Columns`).
export const CROSS_GRID_MIN_WIDTH = 400;
export const CROSS_GRID_MAX_WIDTH = 800;
// 카드에서 그림 높이 밖 몫 — 머리 · 설계 줄 · 꼬리(실측 135).
export const CROSS_CARD_CHROME = 135;
export const CROSS_GRID_GAP = 16;
// bottom은 측점 라벨 한 줄 몫이다. X축 제목을 그래프 상단으로 올린 뒤 남던 여백을 걷어내,
// 종단면도 아래 라벨이 그 밑 유토곡선과의 틈 **가운데**에 오도록 좁혔다(2026-08-02 사용자 지시).
+66 -44
View File
@@ -38,12 +38,22 @@ import {
type CrossPlotBase,
crossPlotBase,
effectiveCardHalfWidth,
naturalHeightAtWidth,
} from "./B06_Section_UI_Cross_View_Metrics";
import { CROSS_HEIGHT } from "./B06_Section_UI_Section_Common";
import { CROSS_GRID_GAP, CROSS_HEIGHT } from "./B06_Section_UI_Section_Common";
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
import { pickCrossColumns } from "./B06_Section_UI_Section_Columns";
import {
CROSS_COLUMNS_BASE,
CROSS_COLUMNS_MAX,
CROSS_COLUMNS_MIN,
clampCrossColumns,
crossCardWidth,
crossRowHeights,
} from "./B06_Section_UI_Section_Columns";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import { createInputField } from "@ui/ui_template_elements";
import {
CROSS_WIDTH,
type CrossDesignChange,
@@ -93,9 +103,9 @@ export function createSectionView(
let currentStationInterval: number | undefined;
let structureEdit: SectionStructureEdit | null = null;
let renderWidth = 0;
let renderHeight = 0;
let drawnColumns = 0;
let resizeTimer = 0;
/** 한 줄 카드 수 — 사용자 값(보기 설정 pref · 138-3). */
let columnCount = clampCrossColumns(readStateRaw("cross-columns"));
// 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신)
let cachedStationInterval = 1;
let cachedCardWidth = CROSS_WIDTH;
@@ -113,6 +123,29 @@ export function createSectionView(
const fillSlopeNotice = createFillSlopeNotice((id) =>
selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true),
);
// 열 수 칸(138-3) — 고정 알림 줄 오른쪽 끝 · 1~6 · 바꾸면 카드 전부 다시 그림
const columnsField = createInputField({
type: "number",
compact: true,
min: CROSS_COLUMNS_MIN,
max: CROSS_COLUMNS_MAX,
value: String(columnCount),
});
columnsField.root.classList.add("b06-section__columns");
columnsField.input.title = L("B06_Cross_Columns");
columnsField.input.addEventListener("change", () => {
const next = clampCrossColumns(columnsField.input.value);
columnsField.input.value = String(next);
if (next === columnCount) return;
columnCount = next;
writeStateRaw("cross-columns", String(next));
draw();
});
fillSlopeNotice.root.append(columnsField.root);
// 카드 scroll-margin-top = 고정 줄 높이 — 고른 카드가 줄 밑에 숨지 않게
new ResizeObserver(() =>
cards.style.setProperty("--b06-notice-h", `${fillSlopeNotice.root.offsetHeight}px`),
).observe(fillSlopeNotice.root);
/** 종단도 한 벌 — 첫 그리기에서 횡단 보기 알림을 건다(`_Section_View_Profile`). */
const profile = createSectionProfile(
@@ -138,14 +171,7 @@ export function createSectionView(
return Number.isFinite(width) ? Math.max(0, width) : 0;
};
const contentHeight = (): number => {
const style = getComputedStyle(cards);
const pad = (value: string): number => Number.parseFloat(value) || 0;
const height = cards.clientHeight - pad(style.paddingTop) - pad(style.paddingBottom);
return Number.isFinite(height) ? Math.max(0, height) : 0;
};
/** 카드 폭에서 앞 `count` 장의 기하 — 한 줄 카드 수 고르기 · 그리기가 함께 쓴다. */
/** 카드 폭에서 앞 `count` 장의 기하. */
const plotBasesFor = (detail: SectionDetailResponse, cardWidth: number, count: number) =>
detail.cross_sections
.slice(0, count)
@@ -158,11 +184,17 @@ export function createSectionView(
designElevationAt(detail.longitudinal.design_profiles, section.chainage_m),
),
);
const naturalHeightsOf = (bases: ReadonlyArray<CrossPlotBase | null | undefined>): number[] =>
bases.map((base) => Math.max(base?.naturalHeight ?? CROSS_HEIGHT, CROSS_HEIGHT));
const pickColumns = (detail: SectionDetailResponse) =>
pickCrossColumns(renderWidth, renderHeight, (cardWidth, count) =>
naturalHeightsOf(plotBasesFor(detail, cardWidth, count)),
/** 3열 폭으로 그렸을 때 높이(바닥 250) — 다른 열 수도 이 비율을 지킴(138-3). */
const baseHeightsOf = (
bases: ReadonlyArray<CrossPlotBase | null | undefined>,
cardWidth: number,
baseWidth: number,
): number[] =>
bases.map((base) =>
Math.max(
base ? naturalHeightAtWidth(base, cardWidth, baseWidth) : CROSS_HEIGHT,
CROSS_HEIGHT,
),
);
/**
@@ -368,14 +400,11 @@ export function createSectionView(
grid.addEventListener("click", (event) => {
if (!(event.target as HTMLElement).closest(".b06-cross-card")) clearSelection();
});
// 열 수 = 보기 칸 높이에 1.5줄이 드는 가장 적은 수 · 카드 폭 400~800(`_Section_Columns`).
const picked = pickColumns(detail);
const columnCount = picked.columns;
drawnColumns = columnCount;
// 열 수 = 사용자 값 · 폭 = (칸 폭 − 간격) ÷ 열 수(`_Section_Columns` · 138-3).
cachedCardWidth = crossCardWidth(renderWidth, columnCount, CROSS_GRID_GAP);
// CSS auto-fill의 자체 열 수와 JS columnCount가 어긋나면 같은 행 높이 그룹핑이 실제
// 렌더 행과 달라져 카드 내부가 뒤죽박죽된다(N-2-6). 열 수 · 폭을 JS가 명시해 일치시킨다.
grid.style.gridTemplateColumns = `repeat(${columnCount}, ${picked.cardWidth}px)`;
cachedCardWidth = picked.cardWidth;
grid.style.gridTemplateColumns = `repeat(${columnCount}, ${cachedCardWidth}px)`;
cachedRowHeight.clear();
if (detail.cross_sections.length) {
// 1차: 각 카드의 자연 높이(250px 바닥 적용) 측정 → 같은 행(columnCount 단위) 최댓값을 행 높이로.
@@ -385,19 +414,18 @@ export function createSectionView(
// 반폭도 카드가 실제로 쓰는 값(`effectiveCardHalfWidth`, 자동 줌아웃 포함)으로 맞춰
// 재 둔다 — 카드·행 높이가 어긋나지 않는다.
const plotBases = plotBasesFor(detail, cachedCardWidth, sections.length);
const naturalHeights = naturalHeightsOf(plotBases);
// 2차: 행별 최댓값으로 강제 높이를 정해 같은 행 카드를 동일 높이로 렌더한다.
for (let start = 0; start < sections.length; start += columnCount) {
const rowHeight = Math.max(...naturalHeights.slice(start, start + columnCount));
for (
let index = start;
index < Math.min(start + columnCount, sections.length);
index += 1
) {
cachedRowHeight.set(sections[index].station_id, rowHeight);
grid.append(buildCrossCard(sections[index], rowHeight, plotBases[index]));
}
}
// 2차: 높이 = 폭 × (3열 높이 ÷ 3열 폭) · 같은 행 최댓값으로 맞춰 동일 높이로 렌더한다.
const baseWidth = crossCardWidth(renderWidth, CROSS_COLUMNS_BASE, CROSS_GRID_GAP);
const rowHeights = crossRowHeights(
baseHeightsOf(plotBases, cachedCardWidth, baseWidth),
baseWidth,
cachedCardWidth,
columnCount,
);
sections.forEach((section, index) => {
cachedRowHeight.set(section.station_id, rowHeights[index]);
grid.append(buildCrossCard(section, rowHeights[index], plotBases[index]));
});
} else {
grid.append(emptyView(L("B06_Profile_View_NoCross")));
}
@@ -441,19 +469,14 @@ export function createSectionView(
else fillSlopeNotice.update(currentDetail.cross_sections, cachedStationInterval);
};
// 높이만 바뀌면(아래 패널 끌기 · 접기) 다시 그리지 않는다 — 열 수는 사용자 값(138-3).
const resizeObserver = new ResizeObserver(() => {
const nextWidth = contentWidth();
const nextHeight = contentHeight();
if (!Number.isFinite(nextWidth) || nextWidth <= 0) return;
const widthMoved = Math.abs(nextWidth - renderWidth) >= 1;
if (!widthMoved && Math.abs(nextHeight - renderHeight) < 1) return;
if (Math.abs(nextWidth - renderWidth) < 1) return;
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
renderWidth = nextWidth;
renderHeight = nextHeight;
// 높이만 바뀌면(아래 패널 끌기 · 접기) 한 줄 카드 수가 바뀔 때만 다시 그린다.
if (!widthMoved && currentDetail && pickColumns(currentDetail).columns === drawnColumns)
return;
draw();
}, 150);
});
@@ -490,7 +513,6 @@ export function createSectionView(
// 진입 시 측점을 자동으로 고르지 않는다(2026-08-02 사용자 지시) — 0측점이 선택된 채로
// 시작하면 사용자가 고르지도 않은 카드가 강조돼 있고 유토곡선 말풍선도 떠 있다.
renderWidth = contentWidth();
renderHeight = contentHeight();
draw();
if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(cards));
},
+37 -1
View File
@@ -44,7 +44,8 @@
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
scroll-margin-top: var(--spacing-8);
/* 고정 알림 줄 높이만큼 띄움(138-3) — 고른 카드가 줄 밑에 숨지 않게 */
scroll-margin-top: calc(var(--b06-notice-h, 0px) + var(--spacing-8));
/* 카드를 **자체 쌓임 맥락**으로 만든다. 이게 없으면 카드 안 오버레이(줌 버튼·면적 표)의
z-index가 최상위 축에서 겨루게 되어 이웃 판 위로 삐져나온다(2026-08-02 사용자 지적). */
isolation: isolate;
@@ -122,6 +123,41 @@
gap: var(--spacing-16);
}
/* 알림 줄(138-3) — 카드 칸 맨 위 고정 · 알림 없어도 줄 유지 · 오른쪽 끝 열 수 칸.
위 여백(카드 칸 padding)만큼 끌어올려 그 틈으로 카드가 비치지 않게 */
.b06-section__notice-bar {
position: sticky;
top: calc(-1 * var(--spacing-16));
z-index: 3;
display: flex;
align-items: flex-start;
gap: var(--spacing-8);
margin-top: calc(-1 * var(--spacing-16));
padding-block: var(--spacing-16) var(--spacing-4);
background: var(--color-bg);
}
.b06-section__notice-blocks {
flex: 1 1 auto;
min-width: 0;
}
.b06-section__notice-blocks > .b06-section__notice:last-of-type {
margin-bottom: 0;
}
.b06-section__columns {
flex: none;
width: 4.5em;
margin-left: auto;
}
/* 펼친 목록이 길면 줄 안에서 스크롤 — 고정 줄이 카드 칸을 다 덮지 않게 */
.b06-section__notice-list {
max-height: 30vh;
overflow: auto;
}
/* 성토사면 5m 초과 경고 줄(2026-09-14) — 요약 한 줄, 펼치면 측점 단추 목록. */
.b06-section__notice {
margin-bottom: var(--spacing-8);
@@ -404,13 +404,6 @@
align-self: center;
}
/* 포장 제안 배지: B05 법정 경사 분석이 포장을 권장한 측점 표시 */
.b06-design__paved-badge {
font-size: 0.72rem;
color: var(--color-warning);
cursor: help;
}
/* 세월교 구체(2026-08-25) — 바닥판은 콘크리트. 측벽은 ㄴ형 집수정 형상을 그대로
쓰므로 `.b06-chart__culvert-basin` 색을 공유한다. */
.b06-chart__ford-slab {
@@ -0,0 +1,89 @@
/* =============================================================================
* B06_Section_UI_Style_Cross_Detail.css
* 횡단 카드 [상세] 단추 · 모달(138-4) — 투명 막(누름 통과) · 글 칸 · CAD 꼴 그림 · B07 수량표.
* ========================================================================== */
/* 측구 줄 맨 오른쪽 — ⋯ 가 보이면 그 옆, 숨으면 혼자 오른쪽 끝 */
.b06-design__detail {
flex: 0 0 auto;
margin-left: auto;
}
.b06-design__more:not([hidden]) ~ .b06-design__detail {
margin-left: 0;
}
/* 막은 투명 · 누름은 아래 화면으로 — 모달 몸만 받음 */
.ui-modal__backdrop.b06-detail__backdrop {
background-color: transparent;
pointer-events: none;
}
.b06-detail__backdrop > .ui-modal.b06-detail {
pointer-events: auto;
overflow: auto;
box-shadow: var(--shadow-lg);
}
.b06-detail__note {
width: 100%;
box-sizing: border-box;
resize: vertical;
font: inherit;
}
/* 가벼운 CAD 꼴 — 어두운 바탕 · 카드 그림 복제에 다크 색 토큰을 덮어씀 */
.b06-detail__cad {
--color-bg: #111111;
--color-surface: #111111;
--color-surface-raised: #1a1a1a;
--color-border: #3a3a3a;
--color-text: #f2f2f2;
--color-text-body: #e6e6e6;
--color-text-secondary: #b8b8b8;
--color-text-muted: #8b8b8b;
--color-primary: #c9a8ff;
--color-warning: #ffd34d;
--color-danger: #ff5f57;
--color-success: #4cd37b;
border-radius: var(--radius-cards);
background: #111111;
color: #f2f2f2;
overflow: hidden;
}
.b06-detail__cad > svg {
display: block;
width: 100%;
height: auto;
}
/* B07 횡단 수량 산출표 — 칸 배치 그대로 */
.b06-detail__table.is-loading {
opacity: 0.6;
}
.b06-detail__qtable {
width: 100%;
border-collapse: collapse;
font-size: var(--text-caption);
font-variant-numeric: tabular-nums;
}
.b06-detail__qtable th,
.b06-detail__qtable td {
padding: 2px 4px;
border: 1px solid var(--color-border);
text-align: center;
white-space: nowrap;
}
.b06-detail__qtable th {
font-weight: 500;
color: var(--color-text-secondary);
}
.b06-detail__qtable tr:first-child > th {
text-align: left;
color: var(--color-text);
}
+39 -100
View File
@@ -13,7 +13,7 @@
`단가.json` = `{파일 이름: {그룹 · 머리 칸 …, 줄|표: [줄]}}` — `master_formula.Master` 가
그대로 읽는 꼴.
계산(`calc`)은 이 묶음과 `입력값.json` · `고른자재.json` · `면적.json`(알약 칸 값) 만 씀 — 마스터
현재값을 읽지 않음. 수량 · 반영 · 합은 일위대가 문서와 같은 함수(`M02_Labor_Doc.settle`).
현재값을 읽지 않음. 셈은 층 일위대가 셈 한 벌(`M02_Labor_Doc.calc` · 142-1 · 빈 입력 = 자동값).
"""
from __future__ import annotations
@@ -265,6 +265,27 @@ def missing_inputs(snapshot: Any, labor: dict[str, Any], values: dict[str, Any])
return out
def with_values(doc: dict[str, Any], values: dict[str, Any], picks: dict[str, Any]) -> dict:
"""사본 문서에 사본 `입력값.json` · `고른자재.json` 을 줄 입력값 · 고름으로 얹음(142-1) —
빈 값은 안 얹음(= 자동값 · M02 층 셈과 같음)."""
lines = []
for one in doc.get("담은로직") or []:
code = one.get("로직") if isinstance(one, dict) else None
if not code:
lines.append(one)
continue
given = {
name: got.get("값")
for name, got in (values.get(code) or {}).items()
if isinstance(got, dict) and got.get("값") not in (None, "")
}
line = {**one, "입력값": given}
if isinstance((picks or {}).get(code), dict):
line["고름"] = picks[code]
lines.append(line)
return {**doc, "담은로직": lines}
def calc(
labor: dict[str, Any],
prices: dict[str, Any],
@@ -272,107 +293,25 @@ def calc(
picks: dict[str, Any],
area: Any = None,
) -> dict[str, Any]:
"""복사본만으로 계산 — 입력이 빠진 로직은 안 돌리고 `멈춤` · 세 비목 · 계는 돈 로직 합
(수량 1 기준).
일위대가 문서(39-1c)면 줄마다 수량 · 반영 · `자재` · `기준합` · `반영합` 을 덧붙임 —
수량 = 손 · 면적 알약 칸(`area`) · 반영 = `M02_Labor_Doc.settle`(문서 계산과 한 벌)."""
snapshot = load_prices(prices)
gaps = missing_inputs(snapshot, labor, values)
blocked = {gap["로직"] for gap in gaps}
"""복사본만으로 계산 — 층 셈(`M02_Labor_Doc.calc`) 한 벌(142-1) · 단가 = 사본 `단가.json` ·
참조 = 굳힌 `참조사본` · 알약 = 가져온 때 것 · 수량 칸 = 사본 `면적.json` · 빈 입력 = 자동값.
답 = 층 셈 꼴(`줄` · `자재` · `기준합` · `반영합`) + `알약` + `빠진입력`(「빈 값 n」 표시)."""
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
sums = dict.fromkeys(mf.COST_ITEMS, Decimal(0))
lines, stuck = [], []
rates = { # 켜쌓기율(85-2) — 문서 계산과 같은 `layered`
one.get("로직"): one.get("켜쌓기율")
for one in (labor.get("줄") or {}).get("담은로직") or []
if isinstance(one, dict)
}
for at, key in enumerate(_logic_keys(labor), start=1):
head: dict[str, Any] = {"차례": at, "로직": key}
if key in blocked:
names = [gap["입력"] for gap in gaps if gap["로직"] == key]
lines.append({**head, "ok": False, "까닭": f"입력 없음 — {', '.join(names)}"})
stuck.append(key)
continue
given = {name: one.get("값") for name, one in (values.get(key) or {}).items()}
try:
with mf.mp.choosing((picks or {}).get(key)):
got = labor_doc.layered(mf.run(snapshot, key, given), rates.get(key))
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as error:
lines.append({**head, "ok": False, "까닭": str(error) or type(error).__name__})
stuck.append(key)
continue
if "결과" in got:
lines.append({**head, "ok": True, "갈래": "결과", "결과": got["결과"]})
continue
for name in mf.COST_ITEMS:
sums[name] += got[name]
lines.append(
{
**head,
"ok": True,
"갈래": "돈",
**{k: got[k] for k in mf.COST_ITEMS},
"계": got["계"],
}
)
for line in _ref_lines(snapshot, labor, len(lines)): # 참조 줄(50-3) — 굳힌 사본으로
if line["ok"]:
for name in mf.COST_ITEMS:
sums[name] += line[name]
else:
stuck.append(line["참조"]["키"])
lines.append(line)
out = {"줄": lines, **sums, "계": sum(sums.values()), "빠진입력": gaps, "멈춤": stuck}
return to_json({**out, **_reflected(snapshot, labor, lines, area)})
def _ref_lines(snapshot: Any, labor: dict[str, Any], start: int) -> list[dict]:
"""참조 줄 — 기준 = 굳힌 사본(`참조사본`)을 복사본 단가로 펼친 1 단위 값
(입력값은 승격 때 고정)."""
from M02_MasterTemplete import M02_Labor_Ref as refs
snapshot = load_prices(prices)
doc = with_values(labor.get("줄") or {}, values or {}, picks or {})
copies = labor.get("참조사본") or {}
expand = refs.Expand(snapshot, lambda ref: copies.get(refs.ref_id(ref)))
out = []
for at, one in enumerate((labor.get("줄") or {}).get("담은로직") or [], start=start + 1):
if not (isinstance(one, dict) and isinstance(one.get("참조"), dict)):
continue
got = expand(one["참조"])
head = {"차례": at, "참조": one["참조"], "이름": got.get("이름"), "분모": got.get("단위")}
if got.get("기준") is None:
out.append({**head, "ok": False, "까닭": got.get("까닭")})
continue
out.append({**head, "ok": True, "갈래": "돈", **got["기준"]})
return out
def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: Any) -> dict:
"""줄마다 수량 · 반영 + 자재 + 기준합 · 반영합 — 기준은 위 줄 그대로 · 알약은 가져온 때 것."""
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
doc = labor.get("줄") or {}
pill_of = {code: pill for pill in labor.get("알약") or [] for code in pill.get("로직") or []}
base = []
for line in lines:
money = {k: line[k] for k in (*mf.COST_ITEMS, "계")} if line.get("갈래") == "돈" else None
if "참조" in line: # 알약 키 = ref_id · 배 1(50-3)
head = {"참조": line["참조"], "알약": labor_doc.line_id(line), "배": 1}
base.append({**head, "분모": line.get("분모"), "기준": money})
continue
pill = pill_of.get(line["로직"]) or {}
head = {"로직": line["로직"], "알약": pill.get("키"), "배": pill.get("배") or 1}
base.append({**head, "분모": pill.get("단위"), "기준": money})
mats = []
for one in doc.get("자재") or []:
code = str(one.get("코드") or "")
got = labor_doc.material_price(snapshot, code, one.get("단위")) # 가격 다섯 · 기준칸(43-3)
mats.append({"코드": code, "알약": labor_doc.material_key(code), **got})
settled = labor_doc.settle(
to_json({"줄": base, "자재": mats}), doc, labor_doc.area_values(area)
saved = labor.get("알약")
got = labor_doc.calc(
doc,
labor_doc.area_values(area),
whole=snapshot,
read=lambda ref: copies.get(refs.ref_id(ref)),
pills=[p for p in saved if p.get("로직")] if isinstance(saved, list) else None,
)
for line, done in zip(lines, settled["줄"]):
line.update({k: done[k] for k in ("알약", "분모", "배", "수량", "수량출처", "반영")})
return {"자재": settled["자재"], "기준합": settled["기준합"], "반영합": settled["반영합"]}
return {
**got,
"알약": saved if isinstance(saved, list) else labor_doc.pills(doc),
"빠진입력": missing_inputs(snapshot, labor, values or {}),
}
+17 -9
View File
@@ -380,16 +380,24 @@ def material_price(master: Any, code: str, unit: str | None) -> dict[str, Any]:
}
def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
def calc(
doc: dict,
given: dict[str, Any] | None = None,
*,
whole: Any = None,
read: Any = None,
pills: list[dict] | None = None,
) -> dict[str, Any]:
"""`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) —
줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음)."""
whole = m01.loaded()[1]
expand = refs.Expand(whole)
by_logic = {
code: pill
for pill in combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
for code in pill["로직"]
}
줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음).
프로젝트 사본 셈(142-1)도 이 한 벌 — `whole` = 사본 `단가.json`(`M02_Item_Prices.load_prices`) ·
`read` = 참조 → 굳힌 `참조사본` · `pills` = 사본 `알약`(가져온 때 것). 없으면 지금 M01 · 층."""
whole = m01.loaded()[1] if whole is None else whole
expand = refs.Expand(whole) if read is None else refs.Expand(whole, read)
if pills is None:
pills = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
by_logic = {code: pill for pill in pills for code in pill.get("로직") or []}
lines = []
for one in doc.get("담은로직") or []:
if _is_ref(one):
@@ -20,11 +20,7 @@ import type {
SpreadsheetHandle,
Workbook,
} from "../A00_Common/spreadsheet/spreadsheet";
import {
readTemplate,
saveTemplate,
type StructureDoc,
} from "./M02_MasterTemplete_Api_Fetch";
import { readTemplate, saveTemplate, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch";
import {
cardShell,
helpButton,
@@ -293,13 +289,14 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
if (!laborCard) return;
laborEd?.destroy();
laborEd = null;
const layer = opts.source().layer;
const src = opts.source();
const layer = src.layer;
let mod: typeof import("./M02_MasterTemplete_Labor_Detail");
let got: Awaited<ReturnType<typeof readTemplate>>;
let got: { 문서: unknown; 판?: string | null };
try {
[mod, got] = await Promise.all([
import("./M02_MasterTemplete_Labor_Detail"),
readTemplate(layer, "labor", at, null),
src.labor ? src.labor.read(at) : readTemplate(layer, "labor", at, null),
]);
} catch (error) {
if (mine === seq) laborCard.shell.fail(fill(TEXT.noSheet, why(error)), reload);
@@ -310,15 +307,24 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
const d = (got.문서 ?? {}) as { 일위대가번?: string };
laborCard.no.textContent =
d.일위대가번 || numberOf("labor", at) || fill(TEXT.noFieldNumber, PART_FIELD.labor);
const via = src.labor; // 자료 자리 길(142-1 · 고르기 모달) — 셈 · 면적도 그 자리에서
laborEd = mod.mountLaborDetail(laborHost, got.문서, {
layer,
column: at,
readOnly: !opts.editable(),
onChanged: (dirty) => laborCard.saver.mark(dirty && opts.editable()),
readOnly: !laborWritable(),
...(via
? {
calc: (doc, 수량) => via.calc(at, doc, 수량),
readArea: () => src.read("area", at),
}
: {}),
onChanged: (dirty) => laborCard.saver.mark(dirty && laborWritable()),
goArea: null, // 면적 칸 잇기는 수량 모달 안(43-1) — 카드에서 화면을 옮기지 않음
card: true,
});
};
/** 일위대가 카드를 고칠 수 있나 — 쓰는 길(`write`)이 없는 자료 자리(고르기 모달)는 보기만(142-1) */
const laborWritable = (): boolean => opts.editable() && !!opts.source().write;
/** 카드 [저장] — 상세와 같은 차례: 고른 칸 연결(면적 문서) 먼저 → 일위대가(판을 다시 읽어 409 막음) */
async function saveLabor(): Promise<void> {
const at = column;
@@ -349,7 +355,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
const sheetEdit = !!opts.sheetEdit?.can();
for (const c of cards) {
const can =
c.kind === "structure" ? drawEdit : c.kind === "labor" ? opts.editable() : sheetEdit;
c.kind === "structure" ? drawEdit : c.kind === "labor" ? laborWritable() : sheetEdit;
c.saver.button.hidden = !can;
c.edit.hidden = !opts.editable();
c.take.hidden = !opts.editable() || !opts.onImport;
@@ -11,6 +11,8 @@ import {
saveTemplate,
type Layer,
} from "./M02_MasterTemplete_Api_Fetch";
import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api";
import type { LaborCalc } from "./M02_MasterTemplete_Labor_Calc";
/** 집계표 열 하나에 딸린 문서 — 구조물 도면 · 면적산출 */
export type ItemPart = "structure" | "area";
@@ -23,6 +25,16 @@ export interface ItemSource {
read: (part: ItemPart, column: string) => Promise<{ 문서: unknown; 판?: string | null }>;
/** 카드 안 [저장](PLAN 31-4) — 전체 편집기와 같은 저장 길 · 없으면 카드는 보기만 */
write?: (part: ItemPart, column: string, 판: string, 문서: unknown) => Promise<{ 판: string }>;
/** 일위대가 카드 자리(142-1) — 읽기 · 셈(고르기 모달 = 프로젝트 복사본 · 원본 층) ·
* 없으면 카드가 지금 층 길(`readTemplate` · `calcLabor`) · `write` 가 없으면 카드는 보기만 */
labor?: {
read: (column: string) => Promise<{ 문서: unknown; 판?: string | null }>;
calc: (
column: string,
doc: LaborDoc,
수량?: Record<string, number>,
) => Promise<LaborCalc & { 알약: unknown[] }>;
};
}
/** M02 화면의 자료 자리 — 부를 때의 지금 층(`getActiveLayer`) 그대로 */
@@ -88,6 +88,9 @@ export interface LaborDetailOptions {
mode?: "precombo";
/** 그 층 주인(134-3 · 사전조합 층 · null = 자기 층) — 없으면 페이지 층 것 */
owner?: string | null;
/** 셈 · 면적 자리 주입(142-1 · 고르기 모달 = 프로젝트 복사본) — 없으면 층 길 */
calc?: (doc: LaborDoc, 수량?: Record<string, number>) => Promise<LaborCalc & { 알약: unknown[] }>;
readArea?: () => Promise<{ 문서: unknown; 판?: string | null }>;
}
export interface LaborDetailHandle {
@@ -245,13 +248,10 @@ export function mountLaborDetail(
draw();
try {
// 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음)
const got = await calcLabor(
opt.layer,
opt.column,
cur,
areaDirty ? given() : undefined,
opt.owner,
);
const qty = areaDirty ? given() : undefined;
const got = opt.calc
? await opt.calc(cur, qty)
: await calcLabor(opt.layer, opt.column, cur, qty, opt.owner);
if (!alive || mine !== seq) return;
calc = last = got;
pills = new Map(
@@ -645,7 +645,7 @@ export function mountLaborDetail(
lib // 서고 모드엔 면적산출 없음
? undefined
: Promise.all([
readTemplate(opt.layer, "area", opt.column, null),
opt.readArea ? opt.readArea() : readTemplate(opt.layer, "area", opt.column, null),
import("../A00_Common/spreadsheet/spreadsheet_recalc"),
]).then(
([got, { recalcWorkbook }]) => {
@@ -8,8 +8,8 @@
PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적은 서버 계산값
POST …/items/{폴더}/reapply 재적용(입력값 · 고른자재는 같은 키면 살림 · `덮임` 알림)
POST …/items/{폴더}/save-as {to, 경로, 이름} 개인 층에 새 항목으로(단가 묶음은 안 가져감)
POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력)
일위대가 문서면 줄마다 수량 · 반영 + 자재 · 기준합 · 반영합
POST …/items/{폴더}/calc 복사본만으로 계산 — 층 셈 한 벌(142-1 · 줄 · 자재 ·
기준합 · 반영합 + 알약 · 빠진입력 · 빈 입력 = 자동값)
종류 = 도면 · 면적 · 입력값 · 고른자재 · 일위대가(읽기만 · 쓰면 400) ·
`refresh` = 옛 이름(재적용).
⚠ 등록은 `main.py`(로그인만) — 프로젝트 권한(같은 회사)은 여기서 `_project` 로 봄(403 · 404).
+2
View File
@@ -60,6 +60,7 @@ from B06_Section.B06_Section_Router_Confirm import (
from B06_Section.B06_Section_Router_HaulPlan import (
router as b06_section_haul_plan_router,
)
from B06_Section.B06_Section_Router_CrossTable import router as b06_section_cross_table_router
from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router
from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router
from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router
@@ -646,6 +647,7 @@ app.include_router(b06_section_router, dependencies=protected_with_company)
app.include_router(b06_section_stations_router, dependencies=protected_with_company)
app.include_router(b06_section_confirm_router, dependencies=protected_with_company)
app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company)
app.include_router(b06_section_cross_table_router, dependencies=protected_with_company)
app.include_router(b07_design_router, dependencies=protected_with_company)
app.include_router(b07_frame_router, dependencies=protected_with_company)
app.include_router(b07_plan_kinds_router, dependencies=protected_with_company)
@@ -0,0 +1,25 @@
/* 138-3 횡단 카드 열 수 · 폭 · 높이 — `B06_Section_UI_Section_Columns.ts` 를 그 자리에서 트랜스파일해 Node 로 돌림. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(__dirname, "..", "..", "B06_Section", "B06_Section_UI_Section_Columns.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", js)(box.exports, box);
const c = box.exports;
// 칸 폭 1232 · 간격 16 → 3열 폭 400
const baseWidth = c.crossCardWidth(1232, 3, 16);
const base = [250, 300, 280, 260, 400];
const out = {
clamp: [undefined, "", "abc", 0, 1, 2.6, 6, 9, "4"].map(c.clampCrossColumns),
widths: [1, 2, 3, 6].map((n) => c.crossCardWidth(1232, n, 16)),
baseWidth,
rows3: c.crossRowHeights(base, baseWidth, baseWidth, 3),
rows2: c.crossRowHeights(base, baseWidth, c.crossCardWidth(1232, 2, 16), 2),
rows1: c.crossRowHeights(base, baseWidth, c.crossCardWidth(1232, 1, 16), 1),
};
process.stdout.write(JSON.stringify(out));
+13
View File
@@ -50,5 +50,18 @@ const out = {
toggledOn: w.toggleRun(runs, off, runs[0]),
stale: w.offForRuns(runs, "20-60,0-5,100-130"),
empty: w.offForRuns([], "20-60"),
// 확폭 면적(138-2) — 앞뒤 확폭 0 측점까지 사다리꼴 · 범위로 자름
area: runs.map((r) => w.wideningArea(stations, r, { start: 0, end: 140 })),
areaClipped: w.wideningArea(stations, runs[0], { start: 30, end: 140 }),
areaSingle: w.wideningArea(
[at(0), at(20, 1, 0), at(40)],
{ start_m: 20, end_m: 20, widening_m: 1 },
{ start: 0, end: 40 },
),
areaMissing: w.wideningArea(
stations,
{ start_m: 999, end_m: 999, widening_m: 1 },
{ start: 0, end: 140 },
),
};
process.stdout.write(JSON.stringify(out));
@@ -143,15 +143,18 @@ globalThis.fetch = async (url, init = {}) => {
}
if (part === "calc")
return reply(200, {
줄: [],
계: 12345.6,
줄: [
{ 로직: "GA1", 까닭: "없는 요소" },
{ 로직: "GA2", 까닭: null },
],
자재: [],
빠진입력: [
{ 로직: "GA1", 입력: "지역" },
{ 로직: "GA2", 입력: "지역" },
],
멈춤: ["GA1"],
기준합: { 계: 100 },
반영합: { 계: 300 },
알약: [],
});
if (method === "GET") {
const content = item.parts[part] ?? null;
@@ -246,10 +249,25 @@ async function main() {
out.calc = await items.calc(info.폴더);
out.calcMissing = await items.calc("없는폴더");
// 일위대가 — 복사본 요약(복사본 계산) · 원본 층 요약(층 calc) · 넣을 값 = 담은 로직 입력 전부
out.laborCopy = await project.laborOf(row(), items, info.폴더);
out.laborOrigin = await project.laborOf(row(), null, null);
out.laborNone = await project.laborOf(row({ 열: "없는열" }), null, null);
// 일위대가(142-1) — 모달 카드 셈 = 복사본 셈 답 통째 · 문서 = 복사본 입력값 · 고른자재를 얹음
const raw = await items.calcLabor(info.폴더);
out.laborCalc = { 줄: raw.줄.length, 반영합: raw.반영합, 빠진입력: raw.빠진입력.length };
out.laborCalcMissing = await items.calcLabor("없는폴더").then(
() => "성공",
(error) => error.message,
);
out.withValues = project.withCopyValues(
{
일위대가번: "SU-000007",
담은로직: [
{ 로직: "GA1", 입력값: { 지역: 1 } },
{ 로직: "GA2", 입력값: { 지역: 2 }, 고름: { 0: "MT1" } },
{ 참조: { 층: "system", 주인: null, 키: "SC-000001" } },
],
},
{ GA1: { 지역: { 값: 15, 확인: true }, 소량감률: { 값: "", 확인: false } } },
{ GA1: { 0: "MT9" } },
);
const valueData = await project.laborValueData(["GA1", "GA2"], { GA1: {} });
out.laborValues = {
keys: state.logicKeys,
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""B06 횡단 카드 열 수 · 폭 · 높이(PLAN 138-3) — 열 수 1~6 · 기본 3 · 폭 = (칸 − 간격) ÷ n · 높이 = 폭 × 3열 비율.
규칙은 Node 헬퍼(`helper_138_cross_columns.cjs`)가 `B06_Section_UI_Section_Columns.ts` 를 그대로 돌림.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_138_cross_columns.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_열_수_1_6_기본_3(out):
assert out["clamp"] == [3, 3, 3, 1, 1, 3, 6, 6, 4]
def test_카드_폭은_칸_폭_나누기_열_수(out):
assert out["widths"] == [1232, 608, 400, (1232 - 80) / 6]
assert out["baseWidth"] == 400
def test_3열은_자연_높이_줄_최댓값(out):
assert out["rows3"] == [300, 300, 300, 400, 400]
def test_다른_열_수는_3열_비율_그대로(out):
# 2열 폭 608 = 3열 400 × 1.52 → 높이도 1.52 배 · 줄(2장) 최댓값
assert [round(h, 6) for h in out["rows2"]] == [456, 456, 425.6, 425.6, 608]
# 1열 폭 1232 = 3.08 배
assert [round(h, 6) for h in out["rows1"]] == [770, 924, 862.4, 800.8, 1232]
+145
View File
@@ -0,0 +1,145 @@
# -*- coding: utf-8 -*-
"""B06 횡단 [상세] 모달(PLAN 138-4) — 구조물 글 새 키 저장 왕복 · B07 수량표 값 POST 한 길.
- 글(`structure_note`): 브라우저 세션 초안 → [저장] patch → 서버 스키마 → 정본 병합 →
재계산(포장 · 세월교 · 사토장)에서도 남음.
- 표: `cross-quantity-table` 이 B07 `_quantity_table` 을 캐시 설계로 돌린 값과 칸 배치를 줌.
./venv/Scripts/python.exe -m pytest resources/tester/test_138_cross_detail.py -q
"""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from B06_Section import B06_Section_Router_CrossTable as table_mod # noqa: E402
from B06_Section import B06_Section_Router_Design as design_mod # noqa: E402
from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import QUANTITY_VALUE_KEYS # noqa: E402
# ── 글 저장 왕복 ─────────────────────────────────────────────────────────
def test_글은_patch_스키마를_지나_정본_병합_값으로_남는다():
patch = CrossSectionPatch(chainage_m=40.0, structure_note="L형 측구 · 집수정 보강")
dumped = patch.model_dump()
# 확정 · 저장 경로와 같은 걸름(`_apply_section_edits`) — 최상위 None 만 걷음
merged = {k: v for k, v in dumped.items() if k != "chainage_m" and v is not None}
assert merged == {"structure_note": "L형 측구 · 집수정 보강"}
# 빈 글 = 지움 — 걸러지지 않고 실림
empty = CrossSectionPatch(chainage_m=40.0, structure_note="").model_dump()
assert empty["structure_note"] == ""
def test_글은_재계산에서도_남는다(monkeypatch):
monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 100.0)])
monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: [])
monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0)
monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {})
monkeypatch.setattr(
design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]}
)
sections = [{"chainage_m": 40.0, "design": {"paved": False, "structure_note": "메모"}}]
assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 1
assert sections[0]["design"]["structure_note"] == "메모"
def test_브라우저가_글을_저장_patch_에_싣는다():
persist = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(
encoding="utf-8"
)
assert "readStructureNotes(" in persist and ".structure_note = note" in persist
state = (PROJECT_ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8")
assert '"structure-notes": { bucket: "draft", scope: "route" }' in state
# ── 수량표 값 ────────────────────────────────────────────────────────────
SOURCE = {"chainage_m": 40.0, "center_z": 101.5, "samples": []}
DESIGN = {
"design_elevation_m": 100.25,
"cut_soil_area_m2": 1.234,
"cut_rock_area_m2": 0.5,
"fill_area_m2": 2.0,
}
def test_표_값은_B07_수량표_한_벌():
sheet = table_mod.cross_quantity_sheet(SOURCE, DESIGN)
table = sheet["quantity_table"]
assert set(QUANTITY_VALUE_KEYS) <= set(table)
assert table["ground"] == 101.5 and table["planned"] == 100.25
assert round(table["cut"], 6) == 1.25 and table["fill"] == 0.0
assert table["cut_soil"] == 1.234 and table["embankment"] == 2.0
# 글은 B07 표에 찍히는 꼴 그대로 · 값 없음 = 「-」
assert sheet["text"]["cut_soil"] == "1.23"
assert sheet["text"]["fence"] == "-"
layout = sheet["layout"]
assert [row[1] for row in layout["header"]] == ["ground", "planned", "fill", "cut"]
assert len(layout["left"]) == len(layout["middle"]) == len(layout["right"]) == 6
def test_설계_없으면_원본_칸만():
sheet = table_mod.cross_quantity_sheet(SOURCE, None)
assert sheet["quantity_table"]["ground"] == 101.5
assert sheet["quantity_table"]["planned"] is None
assert sheet["text"]["cut_soil"] == "-"
class _Pool:
def acquire(self):
pool = self
class _Ctx:
async def __aenter__(self):
return pool
async def __aexit__(self, *exc):
return False
return _Ctx()
def test_POST_한_길이_캐시_설계로_표를_낸다(monkeypatch):
seen = {}
async def longitudinal(connection, project_id, route_id):
return {"longitudinal_file_path": "B05_Profile/route/longitudinal.json"}
async def storage(connection, project_id):
return "proj"
def read_inputs(root, long_path, chainage):
seen["chainage"] = chainage
return [], 100.0, False, SOURCE
monkeypatch.setattr(table_mod, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(table_mod, "get_longitudinal_section", longitudinal)
monkeypatch.setattr(table_mod, "get_project_storage_relative_path", storage)
monkeypatch.setattr(table_mod, "resolve_stored_project_path", lambda rel: "C:/x")
monkeypatch.setattr(table_mod, "read_cross_design_inputs", read_inputs)
app = FastAPI()
app.include_router(table_mod.router)
client = TestClient(app)
url = "/api/projects/00000000-0000-0000-0000-000000000001/sections/7/cross-quantity-table"
response = client.post(url, json={"chainage_m": 40.0, "design": DESIGN})
assert response.status_code == 200, response.text
body = response.json()
assert seen["chainage"] == 40.0
assert body["quantity_table"]["cut_soil"] == 1.234
assert body["text"]["planned"] == "100.25"
# 측점 파일이 없으면 404 · 사유 그대로
def missing(*args):
raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.")
monkeypatch.setattr(table_mod, "read_cross_design_inputs", missing)
response = client.post(url, json={"chainage_m": 41.0, "design": None})
assert response.status_code == 404
assert "횡단 상세 파일" in response.json()["message"]
@@ -25,7 +25,8 @@ def test_이월은_사용자_입력만() -> None:
"ditch_side",
):
assert result_key not in CARRY_INPUT_KEYS
assert {"ground_type", "paved", "ditch_choice", "cut_slope_ratio_user"} <= set(CARRY_INPUT_KEYS)
assert {"ground_type", "ditch_choice", "cut_slope_ratio_user"} <= set(CARRY_INPUT_KEYS)
assert "paved" not in CARRY_INPUT_KEYS # 포장 = 노면공 범위로 새로 정함(138-1)
def test_체인이_옛_설계를_통째로_옮기지_않는다() -> None:
+16
View File
@@ -58,3 +58,19 @@ def test_끈_구간_글_왕복(out):
def test_안_맞는_끈_값은_무시_저장_때_빠짐(out):
assert out["stale"] == "20-60"
assert out["empty"] == ""
def test_확폭_면적_사다리꼴_앞뒤_0_측점까지(out):
# 0~20: (0+0.3)/2×20=3 · 20~40: 10.5 · 40~60: 11.5 · 60~80: 4 → 29
# 80~100: 5 · 100~120: 11 · 120~140: 6 → 22
assert [round(a, 6) for a in out["area"]] == [29.0, 22.0]
def test_확폭_면적_노면공_범위로_자름(out):
# 30 에서 자름: w(30)=0.525 → (0.525+0.75)/2×10=6.375 + 11.5 + 4 = 21.875
assert round(out["areaClipped"], 6) == 21.875
def test_한_측점_구간도_면적_자료_없으면_0(out):
assert round(out["areaSingle"], 6) == 20.0 # (0+1)/2×20 × 2
assert out["areaMissing"] == 0
+32 -7
View File
@@ -126,17 +126,42 @@ def test_missing_truth_files_are_not_an_error(tmp_path):
# ── 포장 판정 규칙 ─────────────────────────────────────────────────────────
def test_outside_range_keeps_user_value_and_defaults_unpaved():
"""경사 제안은 더 이상 포장을 켜지 않는다 — 구간 밖은 사용자 값(없으면 비포장)."""
def test_outside_range_is_unpaved():
"""포장 = 노면공 · 물넘이 범위만(138-1) — 밖은 비포장(옛 저장값 인자 없음)."""
ranges = [(10.0, 20.0)]
assert paved_at(30.0, ranges) is False
assert paved_at(30.0, ranges, True) is True
assert paved_at(30.0, ranges, False) is False
assert paved_at(15.0, ranges) is True
assert paved_at(20.02, ranges) is True # 끝 허용오차 = 브라우저 짝 0.02 m
assert paved_at(20.03, ranges) is False
assert paved_at(15.0, []) is False
def test_inside_range_overrides_user_off_switch():
"""구간 안은 강제 포장 — 물넘이는 콘크리트 노면이고 지정 구간은 자동 반영이다."""
assert paved_at(15.0, [(10.0, 20.0)], False) is True
def test_enforce_turns_paved_on_and_off(monkeypatch):
"""재계산은 켬↔끔 양쪽 — 범위 밖 옛 `paved=true` 는 비포장으로 되돌림(138-1)."""
from pathlib import Path
from B06_Section import B06_Section_Router_Design as design_mod
monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 50.0)])
monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: [])
monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0)
monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {})
monkeypatch.setattr(
design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]}
)
sections = [
{"chainage_m": 20.0, "design": {"paved": False}}, # 범위 안 · 끔 → 켬
{"chainage_m": 80.0, "design": {"paved": True}}, # 범위 밖 · 켬 → 끔
{"chainage_m": 30.0, "design": {"paved": True}}, # 그대로
{"chainage_m": 90.0, "design": {"paved": False}}, # 그대로
]
assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 2
assert [s["design"]["paved"] for s in sections] == [True, False, True, False]
# 노면공을 다 지워도(범위 없음) 옛 켬은 꺼진다
monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [])
assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 2
assert not any(s["design"]["paved"] for s in sections)
# ── 독립 기슭막이 ─────────────────────────────────────────────────────────
+18 -9
View File
@@ -84,6 +84,11 @@ def _read(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def _stuck(got: dict) -> list:
"""못 돈 줄(까닭) — 층 셈 꼴(142-1)."""
return [line for line in got["줄"] if line.get("까닭")]
def _auto_values(root: Path) -> dict[str, Any]:
"""담은 로직마다 자동값을 확인한 값으로 — 사용자가 다 채운 것과 같게."""
labor = _read(root / "templates/structures" / FOLDER / "일위대가.json")
@@ -168,12 +173,13 @@ def _run(master: Any, key: str, values: dict) -> str:
# ── 계산 · 마스터 바뀜 · 새로 가져오기 ───────────────────
def test_입력이_없으면_빠진_입력_목록(world):
def test_입력이_없으면_빠진_입력_목록_셈은_자동값(world):
"""142-1 — 빈 입력은 M02 층 셈과 같이 자동값으로 돎 · 빠진 입력은 「빈 값 n」 표시용 목록."""
root = world["root"]
_save(root, [_pipe()])
got = copies.calc(root, FOLDER)
assert got["계"] == 0 and len(got["멈춤"]) == 3
assert {"로직": "GF000176", "입력": "관경"} in got["빠진입력"]
assert _stuck(got) == [] and got["기준합"]["계"] > 0
def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바뀜(world):
@@ -181,7 +187,7 @@ def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바
_save(root, [_pipe()])
values = _fill_values(root)
before = copies.calc(root, FOLDER)
assert before["빠진입력"] == [] and before["멈춤"] == [] and before["계"] > 0
assert before["빠진입력"] == [] and _stuck(before) == [] and before["기준합"]["계"] > 0
files, whole = m01.loaded()
ua = next(
r
@@ -192,7 +198,8 @@ def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바
)
given = {k: {n: v["값"] for n, v in one.items()} for k, one in values.items()}
expected = prices.to_json(prices.mcb.run(whole, ua, given))
assert before["계"] == pytest.approx(expected["계"]) and before["노무비"] > 0
assert before["기준합"]["계"] == pytest.approx(expected["계"])
assert before["기준합"]["노무비"] > 0
labor_file = master_dir / "인력.json"
original = labor_file.read_bytes()
try:
@@ -204,7 +211,8 @@ def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바
assert copies.list_items(root)[0]["마스터바뀜"] is True
copies.refresh(root, FOLDER)
after = copies.calc(root, FOLDER)
assert after["노무비"] > before["노무비"] and after["재료비"] == before["재료비"]
assert after["기준합"]["노무비"] > before["기준합"]["노무비"]
assert after["기준합"]["재료비"] == before["기준합"]["재료비"]
assert copies.read_file(root, FOLDER, "입력값")["내용"] == values # 같은 키 살림
assert copies.list_items(root)[0]["마스터바뀜"] is False
finally:
@@ -299,7 +307,7 @@ def test_옛_복사본은_읽을_때_채움(world):
assert copies.read_file(root, FOLDER, "입력값")["내용"] == {}
filled = _read(folder / "manifest.json")
assert filled["가져온날"] == filled["적용일"] and filled["옮겨받음"]
assert (folder / "단가.json").is_file() and copies.calc(root, FOLDER)["멈춤"]
assert (folder / "단가.json").is_file() and copies.calc(root, FOLDER)["빠진입력"]
assert copies.list_items(root)[0]["마스터바뀜"] is False # 원본이 안 바뀐 옛 복사본
@@ -328,7 +336,7 @@ def test_길_권한_남의_프로젝트는_403(world, monkeypatch):
stale = client.put(f"{base}/{FOLDER}/입력값", json={"판": got["판"] + "x", "내용": {}})
assert stale.status_code == 409 and stale.json()["detail"]["판"] == put.json()["판"]
assert client.get(f"{base}/{FOLDER}/모름").status_code == 422
assert client.post(f"{base}/{FOLDER}/calc").json()["멈춤"]
assert client.post(f"{base}/{FOLDER}/calc").json()["빠진입력"]
wall = {"structure": "st9", "layer": "system", "owner": None}
assert client.post(base, json={**wall, "summary_item": {"layer": "x"}}).status_code == 400
assert client.post(base, json={"layer": "system", "key": "SS000022"}).status_code == 400
@@ -388,8 +396,9 @@ def test_같은_항목_구조물_둘은_따로(world):
assert copies.read_file(root, "a1", "면적")["내용"]["메모"] == "a1 만 고침"
assert "메모" not in copies.read_file(root, "a2", "면적")["내용"] # 다른 쪽 그대로
_fill_values(root) # pipe@40.0 에만 값
assert copies.calc(root, FOLDER)["멈춤"] == [] and copies.calc(root, FOLDER)["계"] > 0
assert len(copies.calc(root, "pipe@60.0")["멈춤"]) == 3 # 계산도 각자
got = copies.calc(root, FOLDER)
assert _stuck(got) == [] and got["기준합"]["계"] > 0
assert copies.calc(root, "pipe@60.0")["빠진입력"] and not got["빠진입력"] # 계산도 각자
flags = {i["폴더"]: i["고침"] for i in copies.list_items(root)}
assert flags == {"a1": True, "a2": False, FOLDER: True, "pipe@60.0": False}
+36 -25
View File
@@ -82,7 +82,8 @@ def test_재적용은_덮임을_돌려주고_계산_한_줄(out):
assert out["reapplyCall"][0] == "POST /api/projects/P1/items/pipe%4085.05/reapply"
assert out["reapply"]["covered"] == ["면적"] # 덮이는 것을 알림으로
assert out["reapply"]["info"]["마스터바뀜"] is False
assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1, "base": 100, "reflected": 300}
# 142-1 — 계 = 반영합 · 빈 값 = 빠진입력 · 못 돈 줄 = 까닭 있는 줄
assert out["calc"] == {"total": 300, "gaps": 2, "stuck": 1, "base": 100, "reflected": 300}
assert out["calcMissing"] is None # 없는 복사본 = 계산 줄 없음
@@ -140,28 +141,20 @@ def test_넣을_값_카드는_sub2_파일_한_벌(out):
assert "M02_Labor_Values" in text and 'InputsCard"' not in text, name
def test_일위대가_요약_복사본_원본_넣을_값_전부(out):
"""39-1c — 복사본이면 복사본 일위대가 · 계산 · 아니면 원본 층 calc ·
넣을 값 = 담은 로직 입력 전부."""
assert out["laborCopy"] == {
"번호": "SU-000007",
"로직": ["GA1", "GA2"],
"자재": 1,
"base": 100,
"reflected": 300,
"gaps": 2,
"stuck": 1,
}
assert out["laborOrigin"] == {
"번호": None,
"로직": ["GA1", "GA2"],
"자재": 1,
"base": 500,
"reflected": 1500,
"gaps": 0,
"stuck": 1,
}
assert out["laborNone"] is None # 일위대가 없는 열(층 calc 404)
def test_일위대가_카드_셈은_복사본_셈_답_통째_문서는_복사본_값(out):
"""142-1 — 모달 일위대가 카드 = M02 카드 · 셈 = 복사본 셈(층 셈 꼴) · 문서 = 복사본 입력값 ·
고른자재를 줄 입력값 · 고름으로(빈 값 = 자동값이라 안 얹음 · 서버 `with_values` 와 같은 규칙)."""
assert out["laborCalc"] == {"줄": 2, "반영합": {"계": 300}, "빠진입력": 2}
assert out["laborCalcMissing"] == "복사본이 없음" # 서버 까닭 그대로(카드 안 글)
lines = out["withValues"]["담은로직"]
assert lines[0] == {"로직": "GA1", "입력값": {"지역": 15}, "고름": {"0": "MT9"}}
assert lines[1] == {
"로직": "GA2",
"입력값": {},
"고름": {"0": "MT1"},
} # 복사본에 값 없음 = 자동값
assert lines[2] == {"참조": {"층": "system", "주인": None, "키": "SC-000001"}}
assert out["withValues"]["일위대가번"] == "SU-000007"
values = out["laborValues"]
assert values["keys"] == ["GA1", "GA2"]
assert values["logics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역"]]]
@@ -169,9 +162,27 @@ def test_일위대가_요약_복사본_원본_넣을_값_전부(out):
def test_모달_카드_차례_도면_일위대가_면적():
"""142-1 — 일위대가 카드 = M02 카드 한 벌(`createItemCards` 의 `labor`) · 옛 요약 카드 · 넣을 값
카드 · 머리 옛 「일위대가 {조합 이름}」 줄 없음."""
detail = (UI / "ui_template_item_picker_detail.ts").read_text(encoding="utf-8")
assert 'import("./ui_template_item_picker_labor")' in detail
assert "cards.root.insertBefore(labor.root, cards.root.children[1]" in detail
assert not (UI / "ui_template_item_picker_labor.ts").exists()
assert "ui_template_item_picker_labor" not in detail
assert 'labor: () => void edit("inputs"),' in detail # [수정] = 넣을 값 편집 화면
assert "createInputsValueCard" not in detail and "extraCards" not in detail
assert "folderLines" not in detail and "unitPrice" not in detail
def test_모달_일위대가_카드_읽기_전용():
"""142-1 — 모달 자료 자리엔 쓰는 길(`write`)이 없고 카드는 그때 보기만(원본 층 [저장]을 안 엶)."""
detail = (UI / "ui_template_item_picker_detail.ts").read_text(encoding="utf-8")
body = detail[detail.index("const makeSource") : detail.index("const badge")]
assert "write" not in body and "labor: {" in body and "items.calcLabor(at)" in body
cards = (M02 / "M02_MasterTemplete_ItemCards.ts").read_text(encoding="utf-8")
assert "const laborWritable = (): boolean => opts.editable() && !!opts.source().write;" in cards
assert "readOnly: !laborWritable()," in cards
assert 'c.kind === "labor" ? laborWritable()' in cards
picker = (UI / "ui_template_item_picker.ts").read_text(encoding="utf-8")
assert '"personal" : "system"' in picker # 처음 펼침 = 개인(없으면 마스터)
def test_모달은_원본_층에_쓰는_길을_안_부름():
+4 -5
View File
@@ -60,10 +60,8 @@ def test_실패는_카드_안_글과_다시_불러오기():
assert "showToast" not in cards # 토스트 안 씀(저장 알림은 틀)
assert "c.shell.fail(" in cards and "reload" in cards
picker = (ROOT / "ui_template" / "ui_template_item_picker_detail.ts").read_text("utf-8")
# 일위대가 카드 · 값 카드가 다시 불러오기 하나(retry)를 같이 씀(39-1c)
assert "const retry = (): void => void paintLabor(row, seq);" in picker
assert "inputs.set(null, why(error), retry);" in picker
assert "const stops = [labor.loading(), inputs.loading()];" in picker
# 모달 일위대가 카드도 M02 카드 틀 안 실패 글(142-1 · 옛 요약 · 값 카드 retry 없음)
assert "paintLabor" not in picker and 'labor: () => void edit("inputs"),' in picker
def test_일위대가_카드는_상세_카드_갈래():
@@ -75,7 +73,8 @@ def test_일위대가_카드는_상세_카드_갈래():
assert 'makeCard("labor", PART_WORD.labor, laborHost, () => saveLabor())' in cards
save = cards[cards.index("async function saveLabor()") :]
assert save.index("laborEd.saveArea()") < save.index('saveTemplate(layer, "labor"')
assert "onChanged: (dirty) => laborCard.saver.mark(dirty && opts.editable())," in cards
# 고칠 수 있음 = editable + 쓰는 길(142-1 · 고르기 모달은 보기만)
assert "onChanged: (dirty) => laborCard.saver.mark(dirty && laborWritable())," in cards
assert "Labor_View" not in cards # 한 줄 표(40-2) 안 씀
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
assert "move: reorder," in detail # 85-3 — 카드도 순서(▲▼ · 끌기) · 빼기는 그대로
+21 -2
View File
@@ -169,14 +169,15 @@ def test_계산_반영_합은_손셈_원본과_같음(world):
_save(root)
_fill_auto(root)
got = copies.calc(root, FOLDER)
assert got["멈춤"] == [], got["줄"]
assert [line for line in got["줄"] if line.get("까닭")] == [], got["줄"]
lines = {line["로직"]: line for line in got["줄"]}
hand, area = lines[LOGICS[1]], lines[LOGICS[2]]
assert (hand["수량"], hand["수량출처"]) == (HAND, "손")
assert (area["수량"], area["수량출처"]) == (AREA_QTY, "칸")
assert lines[LOGICS[0]]["수량출처"] == "없음" and lines[LOGICS[0]]["반영"] is None
for line in (hand, area):
expect = Decimal(str(line["계"])) * Decimal(str(line["수량"])) / Decimal(str(line["배"]))
base = Decimal(str(line["기준"]["계"]))
expect = base * Decimal(str(line["수량"])) / Decimal(str(line["배"]))
assert Decimal(str(line["반영"]["계"])) == pytest.approx(expect)
(mat,) = got["자재"]
assert mat["코드"] == MATERIAL and mat["수량"] == MAT_QTY and mat["기준단가"]
@@ -208,3 +209,21 @@ def test_일위대가_쓰기는_막힘(world):
assert current["내용"]["줄"]["열"] == COLUMN
with pytest.raises(ValueError, match="원본"):
copies.write_file(root, FOLDER, "일위대가", current["판"], current["내용"])
def test_같은_문서_같은_단가면_사본_셈은_층_셈(world):
"""142-1 — 사본 셈 = 층 셈 한 벌(`M02_Labor_Doc.calc`) · 빈 입력은 둘 다 자동값 · 줄 꼴 같음."""
root = world["root"]
_save(root)
got = copies.calc(root, FOLDER)
given = {world["area_pill"]: AREA_QTY, labor_doc.material_key(MATERIAL): MAT_QTY}
origin = labor_doc.calc(world["doc"], given)
keys = ("로직", "알약", "분모", "배", "기준", "수량", "수량출처", "반영", "까닭")
assert [{k: line.get(k) for k in keys} for line in got["줄"]] == [
{k: line.get(k) for k in keys} for line in origin["줄"]
]
assert got["자재"] == origin["자재"]
assert got["기준합"] == origin["기준합"] and got["반영합"] == origin["반영합"]
assert got["반영합"]["계"] > 0
assert {p["키"] for p in got["알약"]} == set(world["pills"]) # 사본 알약(가져온 때 것)
assert got["빠진입력"] # 비운 입력은 목록으로(화면 「빈 값 n」)
+2 -2
View File
@@ -78,9 +78,9 @@ def test_문서_계산과_프로젝트_사본_같음() -> None:
got = prices.calc(copy, prices.to_json(prices.collect([CODE])), values, {})
origin = labor.calc(doc, {})
(line,) = got["줄"]
assert line["계"] == pytest.approx(origin["줄"][0]["기준"]["계"])
assert line["기준"]["계"] == pytest.approx(origin["줄"][0]["기준"]["계"]) # 층 셈 꼴(142-1)
full = labor.calc({**doc, "담은로직": [{**doc["담은로직"][0], "켜쌓기율": None}]}, {})
assert _d(line["계"]) == pytest.approx(_d(full["줄"][0]["기준"]["계"]) * Decimal("0.9"))
assert _d(line["기준"]["계"]) == pytest.approx(_d(full["줄"][0]["기준"]["계"]) * Decimal("0.9"))
def test_옛_단가승격도_율() -> None:
+3 -3
View File
@@ -373,8 +373,8 @@ def test_프로젝트는_참조사본으로_굳힘_원본을_지워도_같은_
(world["system"] / kind / f"{key}.json").unlink()
got = prices.calc(rows, bundle, {}, {}, None)
line = got["줄"][0]
assert line["ok"] and line["참조"] == ref
assert line["계"] == pytest.approx(live, rel=1e-9)
assert not line["까닭"] and line["참조"] == ref # 층 셈 꼴(142-1)
assert line["기준"]["계"] == pytest.approx(live, rel=1e-9)
assert line["수량"] == 1 and line["반영"]["계"] == pytest.approx(live, rel=1e-9)
@@ -458,7 +458,7 @@ def test_사전조합_로직_줄_섞임_합은_손셈_지우기_409_굳힘(world
for key in (sc, sp): # 원본이 사라져도 굳힌 사본으로 같은 값
(world["system"] / refs.kind_of(key) / f"{key}.json").unlink()
got = prices.calc(rows, bundle, {}, {}, None)["줄"][0]
assert got["계"] == pytest.approx(float(want), rel=1e-9)
assert got["기준"]["계"] == pytest.approx(float(want), rel=1e-9)
def test_사전조합이_자기_자신을_담으면_422_수량_기본_1(world):
+2 -1
View File
@@ -245,7 +245,8 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
for (const id of pathIds(start, labels())) opened.add(id);
chosen = start;
} else {
opened.add("system"); // 지정된 게 없으면 첫 그룹만 펼침
// 지정된 게 없으면 개인(142-1 · M02 기본 층 134-4 와 같게) — 개인 항목이 없으면(회사 없음 등) 마스터
opened.add(rows.some((row) => layerOf(row) === "personal") ? "personal" : "system");
}
paintTree();
paintDetail();
+50 -72
View File
@@ -1,9 +1,10 @@
/* =============================================================================
* ui_template_item_picker_detail.ts
* 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b · 39-1c) — 카드 = 도면 · 일위대가 · 면적산출 ·
* 넣을 값. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `M02_Labor_Values`) + 일위대가 카드
* (`ui_template_item_picker_labor`)를 늦게 받아 씀(B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`).
* 넣을 값 = 일위대가 담은 로직 입력 전부(복사본이면 복사본 입력값).
* 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b · 39-1c · 142-1) — 카드 = 도면 · 일위대가 · 면적산출.
* 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · 일위대가 = `Labor_Detail` 카드 갈래)을 늦게 받아 씀
* (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`). 일위대가 카드 = 자료 자리 길(`ItemSource.labor`) ·
* 읽기 전용(쓰는 길 없음) · 복사본이면 복사본 문서 + 복사본 입력값 · 고른자재 · 셈은 복사본 셈(층 셈 한 벌).
* 넣을 값 고침 = 일위대가 카드 [수정] → 넣을 값 편집 화면.
*
* 자료 자리 — **그 구조물의 복사본**(폴더 = 구조물 id · 같은 항목을 고른 구조물도 복사본은 따로)을 읽고,
* 그 구조물에 아직 없으면 **원본 층**을 읽음(읽기만). [수정] 은 복사본만 고침 — 아직이면 「이 구조물로 가져와 고침」
@@ -14,14 +15,12 @@
import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements";
import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { calcLabor, type LaborDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards";
import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource";
import type { InputsValueHandle } from "../M02_MasterTemplete/M02_Labor_Values";
import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model";
import type { LaborCardHandle } from "./ui_template_item_picker_labor";
import { askFields } from "./ui_template_item_picker_ask";
import {
folderLines,
itemName,
layerOf,
pathOf,
@@ -30,8 +29,6 @@ import {
type PickerRow,
} from "./ui_template_item_picker_data";
import {
laborOf,
laborValueData,
PART_OF,
projectItems,
sameItem,
@@ -39,6 +36,7 @@ import {
type CreatedItem,
type ProjectItemInfo,
type ProjectItems,
withCopyValues,
} from "./ui_template_item_picker_project";
/** 편집 화면을 여는 데 필요한 것 — 「넣을 값」 도 한 종류 */
@@ -105,10 +103,9 @@ const TEXT = {
empty: "{label} 을(를) 넣을 것",
importFailed: "이 구조물로 가져오지 못함 — {value}",
listFailed: "프로젝트 항목을 못 읽음 — {value}",
calc: "복사본 계산 — 계 {total}원 · 빠진 입력 {gaps}개",
calcStuck: " · 멈춘 로직 {stuck}개",
calc: "복사본 계산 — 계 {total}원 · 빈 값 {gaps}개(자동값)",
calcStuck: " · 못 돈 줄 {stuck}개",
noCopy: "복사본에 없음",
price: "일위대가",
parts: {
도면: "도면",
면적: "면적산출",
@@ -131,8 +128,6 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
let seq = 0;
let infos: ProjectItemInfo[] | null = null;
let cards: ItemCardsHandle | null = null;
let inputs: InputsValueHandle | null = null;
let labor: LaborCardHandle | null = null;
let source: ItemSource = { layer: "system", projectId: null, read: () => Promise.reject() };
/** 이 구조물에 있는 복사본 — 고른 항목의 것일 때만(다른 항목의 것이면 `other`) */
let info: ProjectItemInfo | null = null;
@@ -149,7 +144,6 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
const where = [opts.layerLabels[layerOf(row)], ...pathOf(row).slice(0, -1)]
.filter(Boolean)
.join(" › ");
const pairs = folderLines(row).filter((line) => line.kind === "unitPrice");
head.replaceChildren(
el("h4", {
className: "ui-item-picker__name",
@@ -158,29 +152,46 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
}),
small(where),
...(row.단위 ? [small(`${opts.unit} ${row.단위}`)] : []),
...pairs
.filter((line) => line.name || line.key)
.map((line) => small(`${TEXT.price} ${line.name || line.key}`)),
);
};
/** 이 구조물 복사본 또는 원본 층에서 열 하나의 문서를 읽음 — 없으면 던짐(카드가 「없음」) */
const makeSource = (row: PickerRow): ItemSource =>
info && opts.projectId
? {
layer: "project",
projectId: opts.projectId,
read: async (part) => {
const doc = await items?.read(info!.폴더, PART_OF[part]);
if (doc == null) throw new Error(TEXT.noCopy);
return { 문서: doc };
/** 이 구조물 복사본 또는 원본 층에서 열 하나의 문서를 읽음 — 없으면 던짐(카드가 「없음」) ·
* 쓰는 길(`write`)은 없음 — 카드는 보기만(원본 층 · 복사본 고침은 편집 화면 · 142-1) */
const makeSource = (row: PickerRow): ItemSource => {
if (info && opts.projectId && items) {
const at = info.폴더;
return {
layer: "project",
projectId: opts.projectId,
read: async (part) => {
const doc = await items.read(at, PART_OF[part]);
if (doc == null) throw new Error(TEXT.noCopy);
return { 문서: doc };
},
labor: {
read: async () => {
const [labor, values, picks] = await Promise.all([
items.read(at, "일위대가") as Promise<{ 줄?: LaborDoc } | null>,
items.read(at, "입력값") as Promise<ValueDoc | null>,
items.read(at, "고른자재") as Promise<Record<string, unknown> | null>,
]);
return { 문서: labor?.줄 ? withCopyValues(labor.줄, values, picks) : {} };
},
}
: {
layer: layerOf(row),
projectId: null,
read: (part, column) => readTemplate(layerOf(row), part, column, null),
};
calc: () => items.calcLabor(at),
},
};
}
const layer = layerOf(row);
return {
layer,
projectId: null,
read: (part, column) => readTemplate(layer, part, column, null),
labor: {
read: (column) => readTemplate(layer, "labor", column, null),
calc: (column, doc, 수량) => calcLabor(layer, column, doc, 수량, null),
},
};
};
const badge = (text: string, className = ""): HTMLElement =>
el("span", { className: `ui-item-picker__badge ${className}`.trim(), text });
@@ -293,46 +304,17 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
opts.onEdit({ kind, row, info, items, projectId: opts.projectId });
}
/** 일위대가 카드 · 넣을 값 카드 — 복사본이 있으면 복사본(값도) · 아니면 원본 층(39-1c) */
async function paintLabor(row: PickerRow, mine: number): Promise<void> {
if (!inputs || !labor) return;
const stops = [labor.loading(), inputs.loading()]; // 카드 안 「불러오는 중」(M02 31-7)
try {
const copy = info && items ? info.폴더 : null;
const summary = await laborOf(row, copy ? items : null, copy);
const values = copy ? (((await items?.read(copy, "입력값")) as ValueDoc | null) ?? {}) : {};
const data = summary ? await laborValueData(summary.로직, values) : null;
stops.forEach((stop) => stop());
if (mine !== seq) return;
labor.set(summary);
inputs.set(data);
} catch (error) {
stops.forEach((stop) => stop());
if (mine !== seq) return;
const retry = (): void => void paintLabor(row, seq);
labor.set(null, why(error), retry);
inputs.set(null, why(error), retry);
}
}
async function ensureCards(): Promise<void> {
if (cards && inputs && labor) return;
const [cardModule, inputModule, laborModule] = await Promise.all([
import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"),
import("../M02_MasterTemplete/M02_Labor_Values"),
import("./ui_template_item_picker_labor"),
]);
labor = laborModule.createLaborCard();
inputs = inputModule.createInputsValueCard({ mode: "view", onEdit: () => void edit("inputs") });
if (cards) return;
const cardModule = await import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards");
// 차례 = 도면 · 일위대가 · 면적산출(M02 카드 한 벌) · 일위대가 [수정] = 넣을 값 편집 화면
cards = cardModule.createItemCards({
source: () => source,
editable: () => canCopy,
onEdit: (kind) => void edit(kind),
warnOnFail: () => false,
extraCards: [inputs.root],
labor: () => void edit("inputs"),
});
// 차례 = 도면 · 일위대가 · 면적산출 — 일위대가 카드를 도면 카드 바로 뒤에
cards.root.insertBefore(labor.root, cards.root.children[1] ?? null);
cardsHost.replaceChildren(cards.root);
}
@@ -344,8 +326,6 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
status.hidden = true;
cardsHost.replaceChildren();
cards = null;
inputs = null;
labor = null;
return;
}
paintHead(row);
@@ -362,14 +342,12 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
other = own && !info ? own : null;
source = makeSource(row);
await ensureCards();
if (mine !== seq || !cards || !inputs || !labor) return;
if (mine !== seq || !cards) return;
const label = !info && canCopy ? TEXT.importEdit : null;
cards.relabel({ structure: label, area: label });
inputs.setEditLabel(label ?? "넣을 값 수정");
cards.root.hidden = false;
paintStatus(row, mine);
void cards.load({ id: row.열, label: itemName(row) });
void paintLabor(row, mine);
})();
}
@@ -1,60 +0,0 @@
/* =============================================================================
* ui_template_item_picker_labor.ts
* 항목 고르기 모달 「일위대가」 카드(계획서 39-1c) — 담은 로직 수 · 자재 수 · 기준 합 · 반영 합 글.
* 보기만 · 고치기는 원본 층 일위대가 상세. 일위대가 보기 부품(39-3 C)이 오면 몸을 그것으로 바꿈.
* 자료 = `ui_template_item_picker_project.laborOf`(복사본이면 복사본 계산 · 아니면 원본 층 계산).
* 상세(`ui_template_item_picker_detail.ts`)가 카드를 받을 때 함께 `import()`.
* ========================================================================== */
import { el } from "./ui_template_elements";
import { cardNote, cardShell } from "../M02_MasterTemplete/M02_MasterTemplete_CardShell";
import type { LaborSummary } from "./ui_template_item_picker_project";
const TEXT = {
title: "일위대가",
none: "일위대가 없음",
failed: "일위대가를 못 읽음 — {value}",
logics: "단가산출 로직 {value}개",
materials: "자재 {value}개",
base: "기준 합 {value}원",
reflected: "반영 합 {value}원",
gaps: "빠진 입력 {value}개",
stuck: "멈춘 로직 {value}개",
};
const fill = (text: string, value: string | number): string =>
text.replace("{value}", String(value));
const won = (value: number | null): string =>
value === null ? "—" : Math.round(value).toLocaleString();
export interface LaborCardHandle {
root: HTMLElement;
loading: () => () => void;
/** `null` = 일위대가 없음 · `note` 가 있으면 못 읽은 까닭 */
set: (summary: LaborSummary | null, note?: string, retry?: () => void) => void;
}
export function createLaborCard(): LaborCardHandle {
const number = el("span", { className: "m02-struct__no" });
const body = el("div", { className: "m02-struct__sheet ui-item-picker__labor" });
const shell = cardShell({ title: TEXT.title, sub: [number], body });
const line = (text: string, className = ""): HTMLElement =>
el("p", { className: `ui-item-picker__labor-line ${className}`.trim(), text });
return {
root: shell.root,
loading: () => shell.loading(),
set: (summary, note, retry) => {
number.textContent = summary?.번호 ?? "";
if (note && retry) return shell.fail(fill(TEXT.failed, note), retry);
if (!summary) return void body.replaceChildren(cardNote(TEXT.none));
body.replaceChildren(
line(`${fill(TEXT.logics, summary.로직.length)} · ${fill(TEXT.materials, summary.자재)}`),
line(fill(TEXT.base, won(summary.base))),
line(fill(TEXT.reflected, won(summary.reflected)), "is-strong"),
...(summary.gaps ? [line(fill(TEXT.gaps, summary.gaps), "is-warn")] : []),
...(summary.stuck ? [line(fill(TEXT.stuck, summary.stuck), "is-warn")] : []),
);
},
};
}
+49 -65
View File
@@ -9,13 +9,15 @@
* GET|PUT /api/projects/{id}/items/{폴더}/{부분} → {종류, 판, 내용} · 고침은 {판, 내용}(판이 다르면 409)
* POST …/{폴더}/reapply 재적용 → {폴더, 가져온날, 원본판, 덮임: [부분]}
* POST …/{폴더}/save-as {to, 경로, 이름} 개인에 저장 → {key, 열, 이름, summary_item}
* POST …/{폴더}/calc 복사본만으로 계산(계 · 빠진 입력 · 기준합 · 반영합)
* 일위대가(39-1c) — 복사본 `일위대가` 부분 · 원본 층은 `calcLabor` · 넣을 값 목록은 `fetchLogicInputs`
* POST …/{폴더}/calc 복사본만으로 계산 — 층 셈 꼴(줄 · 자재 · 기준합 · 반영합) + 알약 · 빠진입력(142-1)
* 일위대가(39-1c · 142-1) — 모달 일위대가 카드(M02 카드 · 읽기 전용)가 `calcLabor` 로 셈 · 넣을 값 목록은 `fetchLogicInputs`
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { ItemInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { calcLabor, fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import type { LaborDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import type { LaborCalc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Calc";
import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model";
import { layerOf, ownerOf, type PickerRow } from "./ui_template_item_picker_data";
@@ -63,7 +65,10 @@ export interface CreatedItem {
summary_item: { layer: string; owner: string | null; key: string; name: string };
}
/** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빠진 입력 수 · 멈춘 로직 수 · 일위대가 기준합 · 반영합 계 */
/** 복사본 셈 답(142-1) — 층 셈 꼴 + 사본 알약 · 빈 입력 목록(셈은 자동값으로 돎) */
export type CopyCalc = LaborCalc & { 알약: unknown[]; 빠진입력?: unknown[] };
/** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빈 입력 수 · 못 돈 줄 수 · 일위대가 기준합 · 반영합 계 */
export interface ProjectCalc {
total: number;
gaps: number;
@@ -127,6 +132,8 @@ export interface ProjectItems {
) => Promise<CreatedItem>;
/** 복사본만으로 계산 — 계산할 것이 없거나 못 하면 null */
calc: (folder: string) => Promise<ProjectCalc | null>;
/** 복사본 셈 답 통째 — 모달 일위대가 카드(Labor_Detail)가 씀 · 못 하면 서버 까닭 그대로 던짐 */
calcLabor: (folder: string) => Promise<CopyCalc>;
}
const clients = new Map<string, ProjectItems>();
@@ -142,6 +149,12 @@ export function projectItems(projectId: string): ProjectItems {
for (const key of [...versions.keys()]) if (key.startsWith(`${folder}/`)) versions.delete(key);
};
const at = (folder: string, part: string): string => `${base}/${enc(folder)}/${enc(part)}`;
/** 복사본 셈(142-1) — 못 하면 서버 까닭 그대로 던짐 */
const calcCopy = async (folder: string): Promise<CopyCalc> => {
const reply = await call(`${base}/${enc(folder)}/calc`, post({}));
if (reply.status !== 200) throw new Error(reasonOf(reply));
return reply.body as CopyCalc;
};
const client: ProjectItems = {
list: async () => {
@@ -213,78 +226,23 @@ export function projectItems(projectId: string): ProjectItems {
return reply.body as CreatedItem;
},
calc: async (folder) => {
const reply = await call(`${base}/${enc(folder)}/calc`, post({}));
if (reply.status !== 200) return null;
const got = reply.body as {
계?: number;
빠진입력?: unknown[];
멈춤?: unknown[];
기준합?: { 계?: number };
반영합?: { 계?: number };
};
const got = await calcCopy(folder).catch(() => null);
if (!got) return null;
return {
total: Number(got.계 ?? 0),
total: Number(got.반영합?.계 ?? got.기준합?.계 ?? 0),
gaps: got.빠진입력?.length ?? 0,
stuck: got.멈춤?.length ?? 0,
stuck: (got.줄 ?? []).filter((line) => line.까닭).length,
base: got.기준합?.계 ?? null,
reflected: got.반영합?.계 ?? null,
};
},
calcLabor: (folder) => calcCopy(folder),
};
clients.set(projectId, client);
return client;
}
/* ── 일위대가(39-1c) — 카드 요약 · 넣을 값 자료 ─────────────────────────────── */
/** 일위대가 카드에 보이는 것 — 복사본이면 복사본 계산 · 아니면 원본 층 계산(저장 안 함) */
export interface LaborSummary {
번호: string | null;
/** 담은 로직 키(차례 그대로) — 넣을 값 목록을 받는 데도 씀 */
로직: string[];
자재: number;
base: number | null;
reflected: number | null;
gaps: number;
stuck: number;
}
interface LaborCopy {
줄?: { 일위대가번?: string; 담은로직?: { 로직?: string }[]; 자재?: unknown[] };
}
/** 그 항목의 일위대가 — 복사본(`folder`)이 있으면 그것 · 없으면 원본 층 열 · 일위대가가 없으면 null */
export async function laborOf(
row: PickerRow,
items: ProjectItems | null,
folder: string | null,
): Promise<LaborSummary | null> {
if (items && folder) {
const copy = (await items.read(folder, "일위대가")) as LaborCopy | null;
if (!copy?.줄) return null;
const got = await items.calc(folder);
return {
번호: copy.줄.일위대가번 ?? null,
로직: (copy.줄.담은로직 ?? []).map((one) => String(one.로직 ?? "")).filter(Boolean),
자재: copy.줄.자재?.length ?? 0,
base: got?.base ?? null,
reflected: got?.reflected ?? null,
gaps: got?.gaps ?? 0,
stuck: got?.stuck ?? 0,
};
}
const got = await calcLabor(layerOf(row), row.열, null).catch(() => null);
if (!got) return null;
return {
번호: null,
로직: got.줄.map((line) => line.로직),
자재: got.자재.length,
base: got.기준합?.계 ?? null,
reflected: got.반영합?.계 ?? null,
gaps: 0,
stuck: got.줄.filter((line) => line.까닭).length,
};
}
/* ── 일위대가(39-1c) — 넣을 값 자료 ─────────────────────────────── */
/** 넣을 값 카드 자료 — 담은 로직 입력 전부 */
export async function laborValueData(
@@ -295,3 +253,29 @@ export async function laborValueData(
const list: ItemInputs = { 일위대가: null, 로직: got.로직, 빠진로직: got.빠진로직 };
return { list, values };
}
/** 복사본 일위대가 문서에 복사본 입력값 · 고른자재를 줄 입력값 · 고름으로 얹음(보기용 · 142-1) —
* 빈 값은 안 얹음(= 자동값) · 셈은 서버가 같은 규칙으로(`M02_Item_Prices.with_values`) */
export function withCopyValues(
doc: LaborDoc,
values: ValueDoc | null,
picks: Record<string, unknown> | null,
): LaborDoc {
const lines = (doc.담은로직 ?? []).map((one) => {
const code = (one as { 로직?: string }).로직;
if (!code) return one;
const given = Object.fromEntries(
Object.entries(values?.[code] ?? {}).flatMap(([name, got]) => {
const v = (got as { 값?: unknown } | null)?.값;
return v === null || v === undefined || v === "" ? [] : [[name, v]];
}),
);
const pick = picks?.[code];
return {
...one,
입력값: given,
...(pick && typeof pick === "object" ? { 고름: pick } : {}),
} as typeof one;
});
return { ...doc, 담은로직: lines };
}
+3 -3
View File
@@ -447,9 +447,6 @@ export const ui_locales_b2 = {
B06_Design_DitchType_Legend: ["측구형식", "Ditch type"],
B06_Design_DitchType_Standard: ["일반", "Standard"],
B06_Design_DitchType_LType: ["L형", "L-type"],
B06_Design_Paved_Legend: ["포장", "Pavement"],
B06_Design_Paved_On: ["포장", "Paved"],
B06_Design_Paved_Off: ["비포장", "Unpaved"],
B06_Design_TwoStage_Legend: ["2단계 경사", "Two-stage slope"],
B06_Design_TwoStage_On: ["복합경사", "Compound slope"],
B06_Design_TwoStage_Off: ["단경사", "Single slope"],
@@ -475,6 +472,9 @@ export const ui_locales_b2 = {
"측구를 생성한 경우에만 형식을 선택할 수 있습니다.",
"Available only when a ditch is created.",
],
B06_Notice_PaveSuggested: ["포장 권장", "Pavement recommended"],
B06_Cross_Columns: ["한 줄 카드 수(1~6)", "Cards per row (1-6)"],
B06_Design_Detail: ["상세", "Detail"],
B06_Design_Paved_Suggested: [
"종단경사 법정 상한 초과 — 포장 권장 (임도설치 및 관리 등에 관한 규정 별표 1-2)",
"Grade exceeds legal limit — pavement recommended (Forest Road Regulation, Annex 1-2)",