Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import html
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, Response
|
||||
from pymysql.err import IntegrityError
|
||||
@@ -378,9 +379,24 @@ async def dashboard_update_project(
|
||||
if member_ids is not None:
|
||||
# 만든 사람은 늘 참여자로 남는다.
|
||||
await set_project_members(project_id, [int(project["user_id"]), *member_ids])
|
||||
if _range_of(project) != _range_of(data):
|
||||
# 사용 범위가 바뀌면 추천 시점 · 종점(예상노선)도 따라감 · 미리 셈(351-1)
|
||||
from B03_FileInput.B03_FileInput_Router_Helpers import _schedule_background_task
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import refresh_expected_route
|
||||
|
||||
_schedule_background_task(
|
||||
refresh_expected_route(UUID(project_id)), task_name=f"예상노선 다시 씀 {project_id}"
|
||||
)
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
def _range_of(row: dict[str, Any]) -> tuple[float | None, float | None]:
|
||||
return tuple( # type: ignore[return-value]
|
||||
None if row.get(key) is None else float(row[key])
|
||||
for key in ("route_start_m", "route_end_m")
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}")
|
||||
async def dashboard_delete_project(
|
||||
project_id: str,
|
||||
|
||||
@@ -342,15 +342,9 @@ async def run_auto_design_chain(
|
||||
|
||||
# 노선 추천 기본 다섯 알약 미리 셈(PLAN 327-2) — 예약만 · 체인이 [초기화] 요청 안에서도
|
||||
# 불리므로 기다리지 않는다. 같은 입력이면 저장본이 있어 셈 없이 끝난다.
|
||||
try:
|
||||
from B03_FileInput.B03_FileInput_Router_Helpers import _schedule_background_task
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import precompute_defaults
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
|
||||
|
||||
_schedule_background_task(
|
||||
precompute_defaults(project_id), task_name=f"추천 노선 미리 셈 {project_id}"
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 미리 셈이 체인을 막지는 않는다
|
||||
logger.exception("추천 노선 미리 셈 예약 실패: project_id=%s", project_id)
|
||||
schedule_precompute(project_id)
|
||||
|
||||
return {
|
||||
"route_id": route_id,
|
||||
@@ -663,6 +657,10 @@ async def run_redesign_chain(
|
||||
return f"종횡단 확정 실패({sections_result.status_code})"
|
||||
marks.append(("B06 확정(서버 재계산 포함)", time.perf_counter()))
|
||||
_log_steps("재확정 체인", marks)
|
||||
# 지형이 바뀌면 추천 키도 바뀜 — 첫 누름이 저장본이게 미리 셈(351-1)
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
|
||||
|
||||
schedule_precompute(project_id)
|
||||
logger.info(
|
||||
"재확정 체인 완료: project_id=%s route %s→%s (설계 %d건 이월)",
|
||||
project_id,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
키 = 후보 묶음 지문(`_pool_key` — 지형 · 예상노선 · 계류 · 상한 · 최소 R · 이격 · 고름 · 목표
|
||||
기울기) + 갈래 · 비중 · 종단 반영 · 설계속도 · `ALGO_VERSION`. 관 지점은 뺀다 — 덮개 수는 꺼낼 때
|
||||
다시 센다. 추천 결과를 바꾸는 코드 고침은 `ALGO_VERSION` 을 올린다(옛 파일은 다른 키라 안 쓰임).
|
||||
기본값 · 저장된 제약 결과(`default`)는 「최근 벌」을 따로 센다(351-1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -77,12 +78,26 @@ def request_fingerprint(
|
||||
|
||||
|
||||
def is_default(
|
||||
mode: str, weights: dict[str, float] | None, criteria: dict[str, Any] | None, terrain: Any
|
||||
mode: str,
|
||||
weights: dict[str, float] | None,
|
||||
criteria: dict[str, Any] | None,
|
||||
terrain: Any,
|
||||
saved: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""기본값 결과 — 빈 제약 · 50/25/25 · 설계 지형. 표시일 뿐 지우는 데 쓰지 않음."""
|
||||
empty = not any(value is not None for value in (criteria or {}).values())
|
||||
plain = mode != "weighted" or resolve_weights(mode, weights) == DEFAULT_WEIGHTS
|
||||
return empty and plain and terrain is None
|
||||
"""지킬 결과 — 기본값(빈 제약 · 50/25/25 · 설계 지형) 또는 저장된 제약(`initial_choice`) 그대로.
|
||||
|
||||
모달이 여는 값이라 「최근 2벌」 정리에서 따로 센다(351-1 · 사용자 시도에 안 밀림).
|
||||
"""
|
||||
if terrain is not None:
|
||||
return False
|
||||
filled = {key: value for key, value in (criteria or {}).items() if value is not None}
|
||||
used = resolve_weights(mode, weights)
|
||||
if not filled and (mode != "weighted" or used == DEFAULT_WEIGHTS):
|
||||
return True
|
||||
saved = saved or {}
|
||||
return filled == (saved.get("criteria") or {}) and (
|
||||
mode != "weighted" or used == saved.get("weights")
|
||||
)
|
||||
|
||||
|
||||
def read_stored(project_root: Path, key: str) -> dict[str, Any] | None:
|
||||
@@ -125,23 +140,26 @@ def write_stored(
|
||||
"result": result,
|
||||
},
|
||||
)
|
||||
prune_stored(project_root, request)
|
||||
prune_stored(project_root, request, default)
|
||||
return True
|
||||
|
||||
|
||||
def _kind(request: Any) -> tuple[Any, Any]:
|
||||
def _kind(request: Any, default: Any) -> tuple[Any, Any, bool]:
|
||||
request = request if isinstance(request, dict) else {}
|
||||
return request.get("mode"), request.get("terrain")
|
||||
return request.get("mode"), request.get("terrain"), bool(default)
|
||||
|
||||
|
||||
def prune_stored(project_root: Path, request: dict[str, Any]) -> int:
|
||||
"""같은 종류(갈래 · 지형) 저장본을 최근 `KEEP_PER_KIND` 벌만 남기고 지운다. 지운 수."""
|
||||
kind = _kind(request)
|
||||
def prune_stored(project_root: Path, request: dict[str, Any], default: bool = False) -> int:
|
||||
"""같은 종류(갈래 · 지형 · 기본값 여부) 저장본을 최근 `KEEP_PER_KIND` 벌만 남기고 지운다.
|
||||
|
||||
기본값 · 저장된 제약 결과(`default`)는 따로 셈 — 사용자 시도가 밀어내지 않음(351-1). 지운 수.
|
||||
"""
|
||||
kind = _kind(request, default)
|
||||
same: list[tuple[int, Path]] = []
|
||||
for path in store_dir(project_root).glob("*.json"):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if _kind(data.get("request")) == kind:
|
||||
if _kind(data.get("request"), data.get("default")) == kind:
|
||||
same.append((path.stat().st_mtime_ns, path))
|
||||
except (OSError, ValueError, AttributeError):
|
||||
continue
|
||||
|
||||
@@ -76,6 +76,7 @@ async def compute_initial(
|
||||
if paths is None:
|
||||
return JSONResponse(status_code=404, content=replan._PROJECT_PATH_MISSING)
|
||||
project_root, _ = paths
|
||||
current = await asyncio.to_thread(read_initial_choice, project_root)
|
||||
expected = await asyncio.to_thread(replan._vertices_of, expected_route_csv_path(project_root))
|
||||
if not expected:
|
||||
expected = await asyncio.to_thread(replan._vertices_of, design_route_csv_path(project_root))
|
||||
@@ -144,6 +145,7 @@ async def compute_initial(
|
||||
weights,
|
||||
criteria.model_dump() if criteria else None,
|
||||
request.terrain,
|
||||
current,
|
||||
),
|
||||
)
|
||||
profile = result["metrics"].get("profile")
|
||||
@@ -162,7 +164,6 @@ async def compute_initial(
|
||||
result["metrics"]["length_m"],
|
||||
result["metrics"]["offset_avg_m"],
|
||||
)
|
||||
current = await asyncio.to_thread(read_initial_choice, project_root)
|
||||
return {
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
@@ -173,22 +174,99 @@ async def compute_initial(
|
||||
|
||||
|
||||
async def precompute_defaults(project_id: UUID) -> None:
|
||||
"""업로드 체인 끝 예약(PLAN 327-2) — 기본 다섯 알약을 미리 셈해 남김 · 있으면 꺼내기만."""
|
||||
"""체인 끝 예약(PLAN 327-2 · 351-1) — 기본 다섯 알약 + 저장된 제약(initial_choice)의 다섯.
|
||||
|
||||
모달은 저장된 제약 · 비중으로 열리므로 그 값으로도 미리 셈한다. 있으면 꺼내기만.
|
||||
"""
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import DEFAULT_WEIGHTS
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES
|
||||
|
||||
started = time.perf_counter()
|
||||
for mode in DEFAULT_MODES:
|
||||
# 화면 기본과 같은 몸통 — 빈 제약 · 50/25/25 · 설계 지형 · 추적만 종단 끔(`withProfileOf`)
|
||||
request = replan.RouteInitialRequest(mode=mode, with_profile=mode != "follow")
|
||||
outcome = await compute_initial(project_id, request)
|
||||
if isinstance(outcome, JSONResponse):
|
||||
logger.warning(
|
||||
"추천 미리 셈 멈춤(%s %s): project_id=%s", mode, outcome.status_code, project_id
|
||||
variants: list[tuple[Any, Any]] = [(None, None)]
|
||||
try:
|
||||
paths = await replan._project_paths(project_id)
|
||||
except LookupError:
|
||||
paths = None
|
||||
if paths is not None:
|
||||
saved = await asyncio.to_thread(read_initial_choice, paths[0])
|
||||
weights = saved["weights"] if saved["logic"] == "weighted" else None
|
||||
if saved["criteria"] or (weights and weights != DEFAULT_WEIGHTS):
|
||||
variants.append((saved["criteria"], weights))
|
||||
for criteria, weights in variants:
|
||||
for mode in DEFAULT_MODES:
|
||||
# 화면과 같은 몸통 — 설계 지형 · 비중은 비중 갈래만 · 추적만 종단 끔(`withProfileOf`)
|
||||
request = replan.RouteInitialRequest(
|
||||
mode=mode,
|
||||
with_profile=mode != "follow",
|
||||
criteria=criteria,
|
||||
weights=weights if mode == "weighted" else None,
|
||||
)
|
||||
return
|
||||
outcome = await compute_initial(project_id, request)
|
||||
if isinstance(outcome, JSONResponse):
|
||||
logger.warning(
|
||||
"추천 미리 셈 멈춤(%s %s): project_id=%s", mode, outcome.status_code, project_id
|
||||
)
|
||||
return
|
||||
logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started)
|
||||
|
||||
|
||||
def schedule_precompute(project_id: UUID) -> None:
|
||||
"""추천 미리 셈 예약 — 업로드 · 재확정 체인 끝(351-1). 기다리지 않음 · 실패는 로그만."""
|
||||
try:
|
||||
from B03_FileInput.B03_FileInput_Router_Helpers import _schedule_background_task
|
||||
|
||||
_schedule_background_task(
|
||||
precompute_defaults(project_id), task_name=f"추천 노선 미리 셈 {project_id}"
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 미리 셈이 체인을 막지는 않는다
|
||||
logger.exception("추천 노선 미리 셈 예약 실패: project_id=%s", project_id)
|
||||
|
||||
|
||||
async def refresh_expected_route(project_id: UUID) -> None:
|
||||
"""B01 노선 사용 범위를 바꾼 뒤(351-1) — 예상노선을 새 범위로 다시 쓰고 추천 미리 셈.
|
||||
|
||||
체인을 아직 안 돈 프로젝트(예상노선 없음)는 건너뜀 — 체인이 새 범위로 적는다. 설계 노선
|
||||
(수정본 · 초기 폴리라인)은 그대로 — 추천의 시점 · 종점만 따라간다.
|
||||
"""
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from common_util.common_util_route_geometry import load_design_route, write_route_csv
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
surface = await get_surface_confirmation_params(connection, str(project_id))
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT route_start_m, route_end_m FROM projects WHERE id = %s",
|
||||
(str(project_id),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not stored_path:
|
||||
return
|
||||
root = Path(resolve_stored_project_path(stored_path))
|
||||
path = expected_route_csv_path(root)
|
||||
if not path.is_file():
|
||||
return
|
||||
planned = await asyncio.to_thread(
|
||||
functools.partial(
|
||||
load_design_route,
|
||||
root,
|
||||
surface,
|
||||
(row[0], row[1]) if row else None,
|
||||
from_source=True,
|
||||
)
|
||||
)
|
||||
if planned is None:
|
||||
logger.warning("예상노선 다시 쓰기 건너뜀(노선 못 읽음): project_id=%s", project_id)
|
||||
return
|
||||
points = [{"x": v.x, "y": v.y} for v in planned.vertices]
|
||||
await asyncio.to_thread(write_route_csv, path, points)
|
||||
logger.info("예상노선 다시 씀(사용 범위): project_id=%s 점 %d", project_id, len(points))
|
||||
await precompute_defaults(project_id)
|
||||
|
||||
|
||||
def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int:
|
||||
"""종단 정본의 1차 변화점(`base_pvi`) 계획고를 `stations` · `plan` 직선 보간으로 바꿔 쓴다."""
|
||||
import json
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
* - 편집 델타는 항상 **자동 선형(base_pvi) 기준**이라 키를 지우면 정확히 원복된다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pivotAxes } from "./B05_Profile_UI_Profile_Pivot";
|
||||
|
||||
export interface AlignmentPolicy {
|
||||
station_interval_m: number;
|
||||
curve_radius_ratio: number;
|
||||
@@ -552,6 +554,7 @@ const FEEDBACK_TOLERANCE_M = 1e-4;
|
||||
* 변화점이 새로 생기면 종단곡선의 중앙종거만큼 계획고가 함께 내려가(또는 올라가)
|
||||
* 버튼 1클릭이 정확히 0.1m가 되지 않는다. **화면에 보이는 계획고**가 정확히 delta만큼
|
||||
* 움직이도록 중앙종거 변화분을 몇 번 되먹여 보정한다.
|
||||
* 누른 점이 꺾임이면 이웃 꺾임(`pivotAxes`)을 축으로 사이 측점이 새 직선 위로 따라 돈다(355-1).
|
||||
*/
|
||||
export function adjustStation(
|
||||
base: AlignmentBase,
|
||||
@@ -562,17 +565,24 @@ export function adjustStation(
|
||||
const key = chainageKey(chainageM);
|
||||
const current = buildAlignment(base, edits);
|
||||
const target = planElevationAt(current, chainageM) + delta;
|
||||
const baseElevation = interpolate(
|
||||
base.basePvi.map((node) => node.chainage_m),
|
||||
base.basePvi.map((node) => node.elevation_m),
|
||||
chainageM,
|
||||
);
|
||||
const baseS = base.basePvi.map((node) => node.chainage_m);
|
||||
const baseZ = base.basePvi.map((node) => node.elevation_m);
|
||||
const baseElevation = interpolate(baseS, baseZ, chainageM);
|
||||
let offset =
|
||||
(edits.station_offsets[key] ?? planElevationAt(current, chainageM) - baseElevation) + delta;
|
||||
const withOffset = (value: number): AlignmentEdits => ({
|
||||
...edits,
|
||||
station_offsets: { ...edits.station_offsets, [key]: Number(value.toFixed(6)) },
|
||||
});
|
||||
const axes = pivotAxes(current, chainageM);
|
||||
const withOffset = (value: number): AlignmentEdits => {
|
||||
const offsets = { ...edits.station_offsets, [key]: Number(value.toFixed(6)) };
|
||||
const radii = { ...edits.curve_radii };
|
||||
axes?.inner.forEach((chainage) => {
|
||||
const [axisM, axisZ] = chainage < chainageM ? [axes.fromM, axes.fromZ] : [axes.toM, axes.toZ];
|
||||
const z =
|
||||
axisZ + ((baseElevation + value - axisZ) * (chainage - axisM)) / (chainageM - axisM);
|
||||
offsets[chainageKey(chainage)] = Number((z - interpolate(baseS, baseZ, chainage)).toFixed(6));
|
||||
delete radii[chainageKey(chainage)];
|
||||
});
|
||||
return { ...edits, station_offsets: offsets, curve_radii: radii };
|
||||
};
|
||||
let next = withOffset(offset);
|
||||
for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) {
|
||||
const error = target - planElevationAt(buildAlignment(base, next), chainageM);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Pivot.ts
|
||||
* 종단 ▲▼ 축 찾기(355-1 · 요구 235-15) — 누른 점이 꺾임(R 있는 점)이면 좌우로 같은
|
||||
* 기울기 마디를 건너 다음 꺾임(없으면 시점 · 종점)을 축으로 삼는다. 사이 측점은
|
||||
* 새 직선(축 → 누른 점 → 축) 위로 따라 돈다(`adjustStation`).
|
||||
*
|
||||
* 처음 계획선은 마디마다 꺾여 축 = 누른 점 ±1 → 사이 측점 없음(옛 동작 그대로).
|
||||
* `B05_Profile_UI_Profile_Alignment` 를 타입으로만 읽는다(순환 import 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
/** 같은 직선으로 볼 기울기 차이 — `B05_Profile_UI_Profile_Straighten` 과 같은 값. */
|
||||
const COLLINEAR_EPSILON = 1e-6;
|
||||
const SAME_STATION_M = 1e-6;
|
||||
|
||||
export interface PivotAxes {
|
||||
fromM: number;
|
||||
fromZ: number;
|
||||
toM: number;
|
||||
toZ: number;
|
||||
/** 두 축 사이(끝 · 누른 점 제외) 변화점 chainage. */
|
||||
inner: number[];
|
||||
}
|
||||
|
||||
/** 누른 점이 꺾임이면 두 축과 사이 변화점 — 직선 안쪽 · 시종점 · 변화점 아님이면 null. */
|
||||
export function pivotAxes(alignment: ProfileAlignment, chainageM: number): PivotAxes | null {
|
||||
const { pvi, segments } = alignment;
|
||||
const index = pvi.findIndex((node) => Math.abs(node.chainage_m - chainageM) < SAME_STATION_M);
|
||||
if (index <= 0 || index >= pvi.length - 1) return null;
|
||||
const grade = (at: number): number => {
|
||||
const segment = segments[at];
|
||||
return segment && segment.length_m > 1e-12 ? segment.height_m / segment.length_m : 0;
|
||||
};
|
||||
const same = (a: number, b: number): boolean => Math.abs(grade(a) - grade(b)) < COLLINEAR_EPSILON;
|
||||
if (same(index - 1, index)) return null;
|
||||
let from = index - 1;
|
||||
while (from > 0 && same(from - 1, index - 1)) from -= 1;
|
||||
let to = index + 1;
|
||||
while (to < pvi.length - 1 && same(to, index)) to += 1;
|
||||
return {
|
||||
fromM: pvi[from].chainage_m,
|
||||
fromZ: pvi[from].elevation_m,
|
||||
toM: pvi[to].chainage_m,
|
||||
toZ: pvi[to].elevation_m,
|
||||
inner: pvi
|
||||
.slice(from + 1, to)
|
||||
.map((node) => node.chainage_m)
|
||||
.filter((chainage) => Math.abs(chainage - chainageM) >= SAME_STATION_M),
|
||||
};
|
||||
}
|
||||
@@ -290,6 +290,15 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 목록 숫자 — 다 연결(359-2)이면 녹색 · [수정] 은 번호 바로 오른쪽 · 줄 높이 안 커짐 */
|
||||
.m02-hopyo-sheet__item .m02-hopyo__note.is-linked {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__item > .ui-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__result {
|
||||
max-height: 40%;
|
||||
overflow: auto;
|
||||
|
||||
@@ -74,6 +74,14 @@ export function loadDraft(k: string): CheckDraft {
|
||||
return empty();
|
||||
}
|
||||
}
|
||||
/** 확인 창을 열어 만진 적 있음(캐시 있음) */
|
||||
export function hasDraft(k: string): boolean {
|
||||
try {
|
||||
return sessionStorage.getItem(k) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function saveDraft(k: string, d: CheckDraft): void {
|
||||
try {
|
||||
sessionStorage.setItem(k, JSON.stringify(d));
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* M02_MasterTemplete_Hopyo_Ledger.ts
|
||||
* 일위대가 실무자료 별칭 장부 화면(PLAN 338-3 · 설계 4-4) — 본 칸 · 좌측 판 [별칭 장부] 로 엶.
|
||||
* · 표 = 엑셀 품명 · 규격 · 단위 | 갈래 | 우리 로직(여럿) · 옵션 · 배 · 몫 | 누가 · 때 · 처음 파일 | 층(위 층 회색 읽기만)
|
||||
* · 할 일 = 찾기 · 갈래 거름 · 갈래 바꿈 · [로직 바꿈] · [로직 더함] · 배 · 몫 · [옵션] · [지우기] ·
|
||||
* 위 층 줄 [내 층으로 고쳐 씀]
|
||||
* · 뜻(359-4 · 머리 한 줄 · 좌측 단추 title) = 엑셀 품명 ↔ 우리 로직 · 자재 짝을 기억해 두었다가 같은 품명이 나오면 먼저 제안
|
||||
* · 할 일 = 찾기 · 갈래 거름 · 갈래 바꿈 · [로직 바꿈] · [로직 추가] · 배 · 몫 · [옵션] · [삭제] ·
|
||||
* 위 층 줄 [내 층 복사] · 갈래 글 = 확인 창 드롭다운과 같음(짝 · 로직없음 · 사용안함 · 저장 값 그대로)
|
||||
* · 고친 것 = sessionStorage `m02-hopyo-ledger:{층}:{주인}` → [저장] 때만 `PUT`(서버가 짝을 다시 검사)
|
||||
* · 안 둠 = 발주처 칸 · 자동 올림 · 자동 합침 · 쓰인 횟수 · 회사로 올리기.
|
||||
* ========================================================================== */
|
||||
@@ -24,7 +25,16 @@ import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export const LEDGER_TITLE = "별칭 장부";
|
||||
/** 별칭 장부 뜻 한 줄(359-4) — 좌측 단추 title · 화면 머리 */
|
||||
export const LEDGER_HINT =
|
||||
"엑셀 품명 ↔ 우리 로직 · 자재 짝을 기억해 두었다가 같은 품명이 나오면 먼저 제안";
|
||||
const KINDS = ["짝", "우리에 없음", "안 씀"] as const;
|
||||
/** 갈래 화면 글 — 확인 창 드롭다운과 같음(저장 값 그대로) */
|
||||
export const KIND_TEXT: Record<string, string> = {
|
||||
짝: "짝",
|
||||
"우리에 없음": "로직없음",
|
||||
"안 씀": "사용안함",
|
||||
};
|
||||
const LAYER_TEXT: Record<string, string> = { system: "마스터", company: "회사", personal: "개인" };
|
||||
type Edits = Record<string, LedgerEntry | null>;
|
||||
/** 화면 한 줄 — 서버 줄 위에 고친 것 */
|
||||
@@ -136,11 +146,11 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
|
||||
className: "ui-input ui-input--sm",
|
||||
children: [
|
||||
el("option", { text: "갈래 전체", attrs: { value: "" } }),
|
||||
...KINDS.map((x) => el("option", { text: x, attrs: { value: x } })),
|
||||
...KINDS.map((x) => el("option", { text: KIND_TEXT[x], attrs: { value: x } })),
|
||||
],
|
||||
}) as HTMLSelectElement;
|
||||
const save = createButton({ label: "저장", variant: "filled", size: "sm" });
|
||||
const drop = createButton({ label: "고친 것 버림", variant: "ghost", size: "sm" });
|
||||
const drop = createButton({ label: "변경 취소", variant: "ghost", size: "sm" });
|
||||
const body = el("tbody");
|
||||
const keep = (): void => {
|
||||
saveEdits(k, edits);
|
||||
@@ -199,7 +209,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
|
||||
if (mine && r.갈래 === "짝")
|
||||
pairs.push(
|
||||
createButton({
|
||||
label: "로직 더함",
|
||||
label: "로직 추가",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
@@ -217,7 +227,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
|
||||
? (() => {
|
||||
const s = el("select", {
|
||||
className: "ui-input ui-input--sm",
|
||||
children: KINDS.map((x) => el("option", { text: x, attrs: { value: x } })),
|
||||
children: KINDS.map((x) => el("option", { text: KIND_TEXT[x], attrs: { value: x } })),
|
||||
}) as HTMLSelectElement;
|
||||
s.value = r.갈래;
|
||||
s.addEventListener("change", () =>
|
||||
@@ -225,12 +235,12 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
|
||||
);
|
||||
return el("td", { children: [s] });
|
||||
})()
|
||||
: td(r.갈래);
|
||||
: td(KIND_TEXT[r.갈래] ?? r.갈래);
|
||||
const acts: HTMLElement[] = [];
|
||||
if (mine)
|
||||
acts.push(
|
||||
createButton({
|
||||
label: "지우기",
|
||||
label: "삭제",
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
@@ -242,7 +252,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
|
||||
else if (opt.canWrite && !rows.some((x) => x.고침 && x.열쇠 === r.열쇠) && !edits[r.열쇠])
|
||||
acts.push(
|
||||
createButton({
|
||||
label: "내 층으로 고쳐 씀",
|
||||
label: "내 층 복사",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
@@ -316,7 +326,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [
|
||||
el("strong", { text: LEDGER_TITLE }),
|
||||
note("엑셀 이름 → 우리 로직 + 옵션 + 배 · 확인하고 만들 때 남음 · 아래 층이 이김"),
|
||||
note(`${LEDGER_HINT} · 사전조합 생성 때 남음 · 아래 층이 이김`),
|
||||
search,
|
||||
kind,
|
||||
...(opt.canWrite ? [save, drop] : []),
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Sheet.ts
|
||||
* 일위대가 실무자료 엑셀 보기 창(PLAN 329 · 338-3) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거
|
||||
* 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 제안 n/m — 뒤 셈 중이면 3 초마다 다시)
|
||||
* + [고른 n개 확인하고 만들기].
|
||||
* 일위대가 실무자료 엑셀 보기 창(PLAN 329 · 338-3 · 359-1 · 359-2) — 큰 모달 · 머리 막대 = 시트 · 호표 수 · 검산 ·
|
||||
* 제안 n / 줄 m · 숫자 판정 셈 중 · [삭제](좌측 파일 줄 누름으로 바로 엶 · 옛 본 칸 표 몫) ·
|
||||
* 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 스프레드시트 · 탭 일위대가 · 단가산출) ·
|
||||
* 오른쪽 = 호표 목록(체크 · 제N호표 [수정] · 찾기 · 제안 n/m — 다 연결이면 녹색 · 뒤 셈 중이면 3 초마다 다시) + [저장].
|
||||
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름
|
||||
* · ① [고른 n개 확인하고 만들기] = 고른 호표마다 서버 보기 → 펼침 표(넣을 줄 = 체크 제안 · 빠질 줄 · 셈 중 또는 맞음 · 다른 점 수 ·
|
||||
* [확인 창 열기]) ② [이대로 n개 만들기] = 하위 호표부터 차례로 `/hopyo-build`(몸 = 그 호표 체크 줄) —
|
||||
* 펼쳐 보이고 한 번 누름 = 사용자 확인 · 결과 표(사전조합 키 · 빠진 줄 · 값 차이) ·
|
||||
* `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄)
|
||||
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 확인하고 만들기는 됨
|
||||
* · [수정] = 그 호표 확인 창(`openHopyoCheck` · 줄 누름과 따로)
|
||||
* · [저장] = 체크한 호표마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로 하위 호표부터 차례로 `/hopyo-build` —
|
||||
* 물음 한 번(확인 창 안 연 호표 수) · 체크 줄 없는 호표 건너뜀 · 하위 확인 전(409)은 안내 ·
|
||||
* 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄)
|
||||
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 저장은 됨
|
||||
* ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -16,14 +17,17 @@ import {
|
||||
createButton,
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
buildHopyo,
|
||||
deleteHopyo,
|
||||
readHopyo,
|
||||
readHopyoGrid,
|
||||
readHopyoOne,
|
||||
@@ -38,8 +42,8 @@ import {
|
||||
import {
|
||||
bodyOf,
|
||||
draftKey,
|
||||
hasDraft,
|
||||
HOPYO_BUILT,
|
||||
leftOut,
|
||||
loadDraft,
|
||||
openHopyoCheck,
|
||||
partsOf,
|
||||
@@ -64,6 +68,10 @@ export interface HopyoSheetOptions {
|
||||
단산행?: number | [number, number];
|
||||
/** 호표 하나를 만들었음 — [로직 추가] 참조 줄 */
|
||||
onImport?: (got: HopyoBuilt, hopyo: HopyoBrief) => void;
|
||||
/** 창을 닫았음(좌측 파일 줄 고름 표시) */
|
||||
onClose?: () => void;
|
||||
/** [삭제] 했음 — 좌측 목록 다시 · 없으면 [삭제] 단추 없음 */
|
||||
onDeleted?: () => void;
|
||||
}
|
||||
|
||||
const ILWI = "일위대가";
|
||||
@@ -74,6 +82,9 @@ const won = (v: number | null | undefined): string =>
|
||||
v === null || v === undefined ? "" : v.toLocaleString("ko-KR");
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
|
||||
/** 목록 숫자 「연결된 줄 / 연결할 줄」 — 다 연결(같고 0 넘음)이면 참 · 0/0 은 색 없음 */
|
||||
export const allLinked = (m: [number, number]): boolean => m[1] > 0 && m[0] === m[1];
|
||||
|
||||
/** 엑셀 행(1부터)이 든 호표 번 · 없으면 null */
|
||||
export const hopyoAtRow = (list: HopyoSpan[], row: number): number | null =>
|
||||
list.find((h) => h.행[0] <= row && row <= h.행[1])?.번 ?? null;
|
||||
@@ -119,7 +130,11 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
let current: number | null = opt.호표 ?? null;
|
||||
const checked = new Set<number>();
|
||||
const items = new Map<number, HTMLElement>();
|
||||
const target = opt.target ?? opt.scope;
|
||||
|
||||
const head = el("div", { className: "m02-hopyo__bar", children: [note("엑셀을 읽는 중…")] });
|
||||
const warn = el("p", { className: "m02-hopyo__warn" });
|
||||
warn.hidden = true;
|
||||
const left = el("div", {
|
||||
className: "m02-hopyo-sheet__grid",
|
||||
children: [note("엑셀을 읽는 중…")],
|
||||
@@ -130,8 +145,9 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
}) as HTMLInputElement;
|
||||
const list = el("div", { className: "m02-hopyo-sheet__list" });
|
||||
const count = note("");
|
||||
const go = createButton({ label: "고른 0개 확인하고 만들기", variant: "filled", size: "sm" });
|
||||
const go = createButton({ label: "저장", variant: "filled", size: "sm" });
|
||||
go.disabled = true;
|
||||
go.setAttribute("data-hopyo-save", "");
|
||||
const result = el("div", { className: "m02-hopyo-sheet__result" });
|
||||
const right = el("div", {
|
||||
className: "m02-hopyo-sheet__side",
|
||||
@@ -143,25 +159,70 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
],
|
||||
});
|
||||
|
||||
openModal({
|
||||
const finish = (): void => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
sheet?.destroy();
|
||||
opt.onClose?.();
|
||||
};
|
||||
const modal = openModal({
|
||||
title: `엑셀 보기 — ${opt.파일}`,
|
||||
closeLabel: "닫기",
|
||||
wide: true,
|
||||
dialogClass: "m02-modal m02-hopyo-sheet__dialog",
|
||||
beforeClose: async () => {
|
||||
closed = true;
|
||||
sheet?.destroy();
|
||||
finish();
|
||||
return true;
|
||||
},
|
||||
mount: (body) =>
|
||||
body.append(el("div", { className: "m02-hopyo-sheet", children: [left, right] })),
|
||||
body.append(head, warn, el("div", { className: "m02-hopyo-sheet", children: [left, right] })),
|
||||
});
|
||||
|
||||
const paintGo = (): void => {
|
||||
go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 확인하고 만들기`;
|
||||
go.querySelector(".ui-btn__label")!.textContent = checked.size
|
||||
? `저장 (${checked.size})`
|
||||
: "저장";
|
||||
go.disabled = !checked.size;
|
||||
};
|
||||
|
||||
/** 머리 막대 — 옛 본 칸 표 몫(시트 · 호표 수 · 검산 · 제안 · 셈 중 · [삭제]) */
|
||||
function paintHead(file: HopyoView): void {
|
||||
const wrong = file.검산.어긋남;
|
||||
const c = file.셈 && !file.셈.끝 ? file.셈 : null;
|
||||
head.replaceChildren(
|
||||
note(`시트 ${file.시트}`),
|
||||
note(`호표 ${file.호표수}`),
|
||||
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
|
||||
note(`제안 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`),
|
||||
...(c
|
||||
? [note(`숫자 판정 셈 중 ${c.한}/${c.모두 ?? "?"}${c.까닭 ? ` · ${c.까닭}` : ""}`)]
|
||||
: []),
|
||||
...(opt.canWrite && opt.onDeleted ? [dropButton()] : []),
|
||||
);
|
||||
warn.textContent = wrong.join("\n");
|
||||
warn.hidden = !wrong.length;
|
||||
}
|
||||
|
||||
const dropButton = (): HTMLButtonElement =>
|
||||
createButton({
|
||||
label: "삭제",
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: async () => {
|
||||
const ask = `실무자료 「${opt.파일}」을 지움 — 만든 사전조합 · 별칭 장부는 그대로`;
|
||||
if (!(await showConfirmDialog(ask, "삭제"))) return;
|
||||
try {
|
||||
await deleteHopyo(opt.scope, opt.key);
|
||||
showToast("지움", "success");
|
||||
finish();
|
||||
modal.close();
|
||||
opt.onDeleted?.();
|
||||
} catch (error) {
|
||||
showToast(`실패 — ${why(error)}`, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 목록 강조 · 보이게(시트 고름은 그대로) */
|
||||
function mark(no: number | null): void {
|
||||
items.get(current ?? NaN)?.classList.remove("is-current");
|
||||
@@ -180,6 +241,28 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
if (at.시트 !== ilwi || at.범위 !== text) sheet.select(text, ilwi);
|
||||
}
|
||||
|
||||
/** [수정] — 그 호표 확인 창(줄 누름과 따로) */
|
||||
const editButton = (h: HopyoBrief): HTMLElement => {
|
||||
const b = createButton({
|
||||
label: "수정",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
openHopyoCheck({
|
||||
scope: opt.scope,
|
||||
key: opt.key,
|
||||
no: h.번,
|
||||
target,
|
||||
canWrite: opt.canWrite,
|
||||
onBuilt: (got) => opt.onImport?.(got, h),
|
||||
});
|
||||
},
|
||||
});
|
||||
b.setAttribute("data-hopyo-edit", String(h.번));
|
||||
return b;
|
||||
};
|
||||
|
||||
function paintList(): void {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
items.clear();
|
||||
@@ -197,14 +280,17 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
else checked.delete(h.번);
|
||||
paintGo();
|
||||
});
|
||||
const linked = note(`${h.맞춤[0]}/${h.맞춤[1]}`);
|
||||
linked.classList.toggle("is-linked", allLinked(h.맞춤));
|
||||
const row = el("div", {
|
||||
className: `m02-hopyo-sheet__item${h.번 === current ? " is-current" : ""}`,
|
||||
attrs: { "data-hopyo-no": String(h.번), title: `${h.이름} ${h.규격}`.trim() },
|
||||
children: [
|
||||
box,
|
||||
el("span", { className: "m02-hopyo-sheet__no", text: `제${h.번}호표` }),
|
||||
editButton(h),
|
||||
el("span", { className: "m02-hopyo-sheet__name", text: `${h.이름} ${h.규격}`.trim() }),
|
||||
note(`${h.맞춤[0]}/${h.맞춤[1]}`),
|
||||
linked,
|
||||
],
|
||||
});
|
||||
row.addEventListener("click", () => focus(h.번));
|
||||
@@ -243,13 +329,14 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** 뒤 숫자 판정 셈 중이면 3 초 뒤 목록 「제안 n/m」 다시 읽음 — 셈 끝 · 창 닫음 · 못 읽음이면 멈춤 */
|
||||
/** 뒤 숫자 판정 셈 중이면 3 초 뒤 목록 「제안 n/m」 · 머리 다시 읽음 — 셈 끝 · 창 닫음 · 못 읽음이면 멈춤 */
|
||||
function poll(file: HopyoView): void {
|
||||
if (!file.셈 || file.셈.끝) return;
|
||||
window.setTimeout(async () => {
|
||||
const next = closed ? null : await readHopyo(opt.scope, opt.key).catch(() => null);
|
||||
if (!next || closed) return;
|
||||
briefs = next.호표;
|
||||
paintHead(next);
|
||||
paintList();
|
||||
poll(next);
|
||||
}, POLL_MS);
|
||||
@@ -264,6 +351,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
if (closed) return;
|
||||
briefs = file.호표;
|
||||
spans = grid instanceof Error ? [] : grid.호표;
|
||||
paintHead(file);
|
||||
paintList();
|
||||
poll(file);
|
||||
if (grid instanceof Error) return void left.replaceChildren(note(grid.message));
|
||||
@@ -271,7 +359,10 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
if (opt.단산행 !== undefined) sheet?.select(rowsText(opt.단산행), DANSAN);
|
||||
else if (current !== null) focus(current);
|
||||
} catch (error) {
|
||||
if (!closed) left.replaceChildren(note(`못 읽음 — ${why(error)}`));
|
||||
if (!closed) {
|
||||
head.replaceChildren(note(`못 읽음 — ${why(error)}`));
|
||||
left.replaceChildren(note(`못 읽음 — ${why(error)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,84 +388,50 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
|
||||
return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null;
|
||||
}
|
||||
const headCell = (no: number, name: string): HTMLElement =>
|
||||
el("td", { text: `제${no}호표 ${name}`.trim() });
|
||||
|
||||
/** ① 고른 호표마다 서버 보기 → 펼침 표 */
|
||||
async function review(): Promise<void> {
|
||||
const target = opt.target ?? opt.scope;
|
||||
/** [저장] — 물음 한 번 → 고른 호표마다 서버 보기 → 체크 줄 있는 것만 만들기(체크 줄 없으면 건너뜀) */
|
||||
async function save(): Promise<void> {
|
||||
const nos = [...checked].sort((a, b) => a - b);
|
||||
const fresh = nos.filter((no) => !hasDraft(draftKey(opt.scope, opt.key, no))).length;
|
||||
const ask =
|
||||
`고른 호표 ${nos.length}개를 체크한 줄로 사전조합 생성` +
|
||||
(fresh ? ` — 확인 창을 안 연 ${fresh}개는 서버 제안 체크 줄` : "");
|
||||
if (!(await showConfirmDialog(ask, "저장"))) return;
|
||||
go.disabled = true;
|
||||
const ones: HopyoOne[] = [];
|
||||
for (const no of nos) {
|
||||
if (closed) return;
|
||||
try {
|
||||
ones.push(await readHopyoOne(opt.scope, opt.key, no, target));
|
||||
} catch (error) {
|
||||
showToast(`제${no}호표 — ${why(error)}`, "error");
|
||||
const skipped: HTMLElement[] = [];
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
for (const no of nos) {
|
||||
if (closed) return;
|
||||
try {
|
||||
ones.push(await readHopyoOne(opt.scope, opt.key, no, target));
|
||||
} catch (error) {
|
||||
skipped.push(el("tr", { children: [headCell(no, ""), el("td", { text: why(error) })] }));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
paintGo();
|
||||
}
|
||||
paintGo();
|
||||
const rows = ones.map((one) => {
|
||||
const h = one.호표;
|
||||
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
|
||||
const out = Object.entries(leftOut(h.줄, d));
|
||||
const tally = (s: string): number => h.줄.filter((l) => l.맞춤.상태 === s).length;
|
||||
const calc =
|
||||
h.셈 && !h.셈.끝
|
||||
? `셈 중 ${h.셈.한}/${h.셈.모두}`
|
||||
: `맞음 ${tally("맞음")} · 다른 점 ${tally("다른 점")}`;
|
||||
const brief = briefs.find((x) => x.번 === h.번) ?? h;
|
||||
return el("tr", {
|
||||
attrs: { "data-review": String(h.번) },
|
||||
children: [
|
||||
el("td", { text: `제${h.번}호표 ${h.이름}`.trim() }),
|
||||
el("td", {
|
||||
text: wholeFor(one) ? "통째" : String(bodyFor(one).filter((x) => x.대상).length),
|
||||
className: "m02-hopyo__num",
|
||||
}),
|
||||
el("td", { text: out.map(([a, b]) => `${a} ${b}`).join(" · ") }),
|
||||
el("td", { text: calc }),
|
||||
el("td", {
|
||||
const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one));
|
||||
for (const one of ones)
|
||||
if (!ready.includes(one))
|
||||
skipped.push(
|
||||
el("tr", {
|
||||
children: [
|
||||
createButton({
|
||||
label: "확인 창 열기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openHopyoCheck({
|
||||
scope: opt.scope,
|
||||
key: opt.key,
|
||||
no: h.번,
|
||||
target,
|
||||
canWrite: opt.canWrite,
|
||||
onBuilt: (got) => opt.onImport?.(got, brief),
|
||||
}),
|
||||
}),
|
||||
headCell(one.호표.번, one.호표.이름),
|
||||
el("td", { text: "건너뜀 — 체크 줄 없음" }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one));
|
||||
const all = createButton({
|
||||
label: `이대로 ${ready.length}개 만들기`,
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
disabled: !ready.length,
|
||||
onClick: () => void makeAll(ready),
|
||||
});
|
||||
all.setAttribute("data-hopyo-make-all", "");
|
||||
result.replaceChildren(
|
||||
table(["호표", "넣을 줄", "빠질 줄", "숫자 판정", ""], rows),
|
||||
el("div", {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [all, note("넣을 줄 = 체크 제안 · 확인 창에서 고친 것")],
|
||||
}),
|
||||
);
|
||||
);
|
||||
await makeAll(ready, skipped);
|
||||
}
|
||||
|
||||
/** ② 한 번 누름 = 사용자 확인 — 하위 호표부터 차례로 만들기 */
|
||||
async function makeAll(ones: HopyoOne[]): Promise<void> {
|
||||
const target = opt.target ?? opt.scope;
|
||||
/** 하위 호표부터 차례로 만들기 — 하위 확인 전(409)은 안내 */
|
||||
async function makeAll(ones: HopyoOne[], skipped: HTMLElement[]): Promise<void> {
|
||||
const by = new Map(ones.map((one) => [one.호표.번, one]));
|
||||
const order = buildOrder(
|
||||
ones.map((one) => {
|
||||
@@ -392,7 +449,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
for (const no of order) {
|
||||
const one = by.get(no)!;
|
||||
const h = briefs.find((x) => x.번 === no) ?? one.호표;
|
||||
const head = el("td", { text: `제${no}호표 ${h.이름}`.trim() });
|
||||
const cell = headCell(no, h.이름);
|
||||
try {
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no };
|
||||
const got = await buildHopyo(target, from, bodyFor(one), wholeFor(one));
|
||||
@@ -402,7 +459,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
el("tr", {
|
||||
attrs: { "data-built": got.참조.키 },
|
||||
children: [
|
||||
head,
|
||||
cell,
|
||||
el("td", { text: `${got.참조.키} · ${builtWord(got)}` }),
|
||||
el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }),
|
||||
el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }),
|
||||
@@ -410,17 +467,24 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] }));
|
||||
const text =
|
||||
error instanceof StaleError
|
||||
? `안내 — ${why(error)} · 하위 호표를 함께 고르거나 먼저 저장`
|
||||
: `실패 — ${why(error)}`;
|
||||
rows.push(el("tr", { children: [cell, el("td", { text })] }));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
if (ok) document.dispatchEvent(new Event(HOPYO_BUILT));
|
||||
result.replaceChildren(table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], rows));
|
||||
showToast(`만듦 ${ok} / ${order.length}`, ok === order.length ? "success" : "warning");
|
||||
result.replaceChildren(
|
||||
table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], [...rows, ...skipped]),
|
||||
);
|
||||
const all = order.length + skipped.length;
|
||||
showToast(`만듦 ${ok} / ${all}`, ok === all ? "success" : "warning");
|
||||
}
|
||||
go.addEventListener("click", () => void review());
|
||||
go.addEventListener("click", () => void save());
|
||||
|
||||
void load();
|
||||
}
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Side.ts
|
||||
* 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) ·
|
||||
* [엑셀 올리기](숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + 파일 줄 → 누르면 본 화면
|
||||
* (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만 ·
|
||||
* 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶 ·
|
||||
* 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 359-1 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) ·
|
||||
* [파일 업로드](구조물 조합 층 단추 꼴 `m02-layers__btn` · 숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) +
|
||||
* 파일 줄 → 누르면 바로 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴 · 지우기 · 검산 · 셈 중은 창 머리) ·
|
||||
* 고름 표시 = 그 파일 창이 열린 동안 · 올린 직후도 엑셀 보기 창 ·
|
||||
* 파일 목록 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { listHopyo, pageScope, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { buildHopyoLedger, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger";
|
||||
import { buildHopyoView } from "./M02_MasterTemplete_Hopyo_View";
|
||||
import { buildHopyoLedger, LEDGER_HINT, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger";
|
||||
|
||||
export const HOPYO_TITLE = "일위대가 실무자료";
|
||||
|
||||
export interface HopyoSectionOptions {
|
||||
/** 이 층 쓰기 권한 — 없으면 올리기 꺼짐 · 본 화면 확정 · 만들기 없음 */
|
||||
/** 이 층 쓰기 권한 — 없으면 올리기 꺼짐 · 엑셀 보기 창 보기만 */
|
||||
canWrite: boolean;
|
||||
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
@@ -42,22 +41,19 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
attrs: { type: "file", accept: ".xls,.xlsx", "data-hopyo-file": "" },
|
||||
}) as HTMLInputElement;
|
||||
input.hidden = true;
|
||||
// 구조물 조합 층 단추(`TreePanel_Roots` [마스터])와 같은 꼴 — 보통 크기 · 같은 클래스
|
||||
const upload = createButton({
|
||||
label: "엑셀 올리기",
|
||||
label: "파일 업로드",
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
disabled: !opt.canWrite,
|
||||
onClick: () => input.click(),
|
||||
});
|
||||
upload.classList.add("m02-layers__btn");
|
||||
upload.title = opt.canWrite ? "설계내역서 엑셀(일위대가 시트)" : "이 층 쓰기 권한 없음";
|
||||
const ledger = el("button", {
|
||||
className: "m02-side__item ui-sidebar-row",
|
||||
text: LEDGER_TITLE,
|
||||
attrs: {
|
||||
type: "button",
|
||||
title: "엑셀 이름 → 우리 로직 짝 보기 · 고치기",
|
||||
"data-hopyo-ledger-open": "",
|
||||
},
|
||||
attrs: { type: "button", title: LEDGER_HINT, "data-hopyo-ledger-open": "" },
|
||||
});
|
||||
ledger.addEventListener("click", () => void showLedger());
|
||||
const body = el("div", {
|
||||
@@ -66,6 +62,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
});
|
||||
let files: HopyoFile[] = [];
|
||||
let open: { key: string; view: HTMLElement } | null = null;
|
||||
let sheetKey: string | null = null; // 엑셀 보기 창이 열린 파일
|
||||
let seq = 0;
|
||||
|
||||
input.addEventListener("change", () => {
|
||||
@@ -78,9 +75,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
const wrong = got.어긋남 ? ` · 검산 어긋남 ${got.어긋남}` : "";
|
||||
showToast(`올림 — 호표 ${got.호표수}${wrong}`, got.어긋남 ? "warning" : "success");
|
||||
await refresh();
|
||||
await show(got);
|
||||
const sheet = await import("./M02_MasterTemplete_Hopyo_Sheet");
|
||||
sheet.openHopyoSheet({ scope, key: got.키, 파일: got.파일, canWrite: opt.canWrite });
|
||||
await showSheet(got);
|
||||
})
|
||||
.catch((error) => showToast(why(error), "error"))
|
||||
.finally(() => (upload.disabled = !opt.canWrite));
|
||||
@@ -92,32 +87,37 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
...(files.length
|
||||
? files.map((f) => {
|
||||
const row = el("button", {
|
||||
className: `m02-side__item ui-sidebar-row${open?.key === f.키 ? " is-active" : ""}`,
|
||||
className: `m02-side__item ui-sidebar-row${sheetKey === f.키 ? " is-active" : ""}`,
|
||||
text: `${f.파일} · 호표 ${f.호표수}`,
|
||||
attrs: { type: "button", title: `${f.키} · ${f.올린때}`, "data-hopyo-key": f.키 },
|
||||
});
|
||||
row.addEventListener("click", () => void show(f));
|
||||
row.addEventListener("click", () => void showSheet(f));
|
||||
return row;
|
||||
})
|
||||
: [el("p", { className: "m02-side__empty", text: "올린 실무자료 없음" })]),
|
||||
);
|
||||
}
|
||||
|
||||
async function show(file: HopyoFile): Promise<void> {
|
||||
if (open?.key === file.키 && open.view.isConnected) return;
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
const view = buildHopyoView(file.키, {
|
||||
scope,
|
||||
canWrite: opt.canWrite,
|
||||
onDeleted: () => {
|
||||
view.replaceChildren(el("p", { className: "m02-side__empty", text: "지운 실무자료" }));
|
||||
void refresh();
|
||||
},
|
||||
/** 파일 줄 누름 · 올린 직후 = 바로 엑셀 보기 창(본 칸은 그대로 · 열 때 불러옴) */
|
||||
async function showSheet(file: HopyoFile): Promise<void> {
|
||||
const sheet = await import("./M02_MasterTemplete_Hopyo_Sheet").catch((error: unknown) => {
|
||||
showToast(why(error), "error");
|
||||
return null;
|
||||
});
|
||||
opt.show(`${HOPYO_TITLE} — ${file.파일}`, view);
|
||||
opt.onOpen();
|
||||
open = { key: file.키, view };
|
||||
if (!sheet) return;
|
||||
sheetKey = file.키;
|
||||
paintList();
|
||||
sheet.openHopyoSheet({
|
||||
scope,
|
||||
key: file.키,
|
||||
파일: file.파일,
|
||||
canWrite: opt.canWrite,
|
||||
onClose: () => {
|
||||
if (sheetKey === file.키) sheetKey = null;
|
||||
paintList();
|
||||
},
|
||||
onDeleted: () => void refresh(),
|
||||
});
|
||||
}
|
||||
|
||||
async function showLedger(): Promise<void> {
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_View.ts
|
||||
* 일위대가 실무자료 본 화면(PLAN 324-4 · 338-3) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 제안 n/m · 호표 표 + 찾기 ·
|
||||
* [지우기] · [엑셀로 보기](329 엑셀 보기 창 · 늦게 불러옴) / 호표 하나 = 확인 창(`M02_MasterTemplete_Hopyo_Check` ·
|
||||
* 체크한 줄만 [확인하고 사전조합 만들기]) — 즉시 확정 · 바로 만들기 단추 없음.
|
||||
* 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import {
|
||||
deleteHopyo,
|
||||
readHopyo,
|
||||
type HopyoScope,
|
||||
type HopyoView,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { buildHopyoCheck, POLL_MS } from "./M02_MasterTemplete_Hopyo_Check";
|
||||
import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export interface HopyoViewOptions {
|
||||
scope: HopyoScope;
|
||||
/** 층 쓰기 권한 — 없으면 체크 · 만들기 · 지우기 단추 없음 */
|
||||
canWrite: boolean;
|
||||
/** 파일을 지웠음 — 좌측 목록 다시 */
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const won = (v: number | null | undefined): string =>
|
||||
v === null || v === undefined ? "" : v.toLocaleString("ko-KR");
|
||||
const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error");
|
||||
/** 엑셀 보기 창(329) — 열 때 불러옴 */
|
||||
const openSheet = (o: HopyoSheetOptions): void =>
|
||||
void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail);
|
||||
|
||||
const grid = (heads: string[], body: HTMLElement): HTMLElement =>
|
||||
el("div", {
|
||||
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-hopyo__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const td = (text: string, cls = ""): HTMLElement =>
|
||||
el("td", { text, className: cls, attrs: { title: text } });
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
|
||||
export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement {
|
||||
const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } });
|
||||
let nav = 0; // 늦게 온 응답 버림
|
||||
let query = "";
|
||||
|
||||
async function load<T>(get: () => Promise<T>, retry: () => void): Promise<T | null> {
|
||||
const my = ++nav;
|
||||
const stop = showLoading(view);
|
||||
try {
|
||||
const got = await get();
|
||||
return my === nav ? got : null;
|
||||
} catch (error) {
|
||||
if (my === nav) view.replaceChildren(loadingError(why(error), retry));
|
||||
return null;
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 파일 하나 — 호표 표 ─────────────────────────────────────── */
|
||||
async function drawFile(): Promise<void> {
|
||||
const file = await load(
|
||||
() => readHopyo(opt.scope, key),
|
||||
() => void drawFile(),
|
||||
);
|
||||
if (!file) return;
|
||||
let hopyos = file.호표;
|
||||
const search = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__search",
|
||||
attrs: { type: "search", placeholder: "호표 · 품명 · 규격 찾기", value: query },
|
||||
}) as HTMLInputElement;
|
||||
const body = el("tbody");
|
||||
const paint = (): void => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
query = search.value;
|
||||
body.replaceChildren(
|
||||
...hopyos
|
||||
.filter((h) => !q || `제${h.번}호표 ${h.이름} ${h.규격}`.toLowerCase().includes(q))
|
||||
.map((h) => {
|
||||
const tr = el("tr", {
|
||||
className: "m02-hopyo__row",
|
||||
attrs: { "data-hopyo-no": String(h.번) },
|
||||
children: [
|
||||
td(`제${h.번}호표`),
|
||||
td(h.이름),
|
||||
td(h.규격),
|
||||
td(h.단위),
|
||||
td(won(h.금액.계), "m02-hopyo__num"),
|
||||
td(`${h.맞춤[0]} / ${h.맞춤[1]}`, "m02-hopyo__num"),
|
||||
],
|
||||
});
|
||||
tr.addEventListener("click", () => void drawOne(h.번));
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
};
|
||||
search.addEventListener("input", paint);
|
||||
paint();
|
||||
// 뒤 숫자 판정 셈 중이면 3 초마다 다시 읽어 「제안 n / 줄 m」 갱신 — 셈 끝 · 다른 화면 · 못 읽음이면 멈춤
|
||||
const ready = note("");
|
||||
const calc = note("");
|
||||
const show = (f: HopyoView): void => {
|
||||
ready.textContent = `제안 ${f.맞춤[0]} / 줄 ${f.맞춤[1]}`;
|
||||
const c = f.셈 && !f.셈.끝 ? f.셈 : null;
|
||||
calc.textContent = c
|
||||
? `숫자 판정 셈 중 ${c.한}/${c.모두 ?? "?"}${c.까닭 ? ` · ${c.까닭}` : ""}`
|
||||
: "";
|
||||
calc.hidden = !c;
|
||||
};
|
||||
const my = nav;
|
||||
const live = (): boolean => my === nav && view.isConnected;
|
||||
const poll = (f: HopyoView): void => {
|
||||
if (!f.셈 || f.셈.끝) return;
|
||||
window.setTimeout(async () => {
|
||||
const next = live() ? await readHopyo(opt.scope, key).catch(() => null) : null;
|
||||
if (!next || !live()) return;
|
||||
hopyos = next.호표;
|
||||
show(next);
|
||||
paint();
|
||||
poll(next);
|
||||
}, POLL_MS);
|
||||
};
|
||||
show(file);
|
||||
poll(file);
|
||||
const wrong = file.검산.어긋남;
|
||||
view.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [
|
||||
el("strong", { text: file.파일 }),
|
||||
note(`시트 ${file.시트}`),
|
||||
note(`호표 ${file.호표수}`),
|
||||
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
|
||||
ready,
|
||||
calc,
|
||||
createButton({
|
||||
label: "엑셀로 보기",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openSheet({ scope: opt.scope, key, 파일: file.파일, canWrite: opt.canWrite }),
|
||||
}),
|
||||
...(opt.canWrite ? [dropButton(file)] : []),
|
||||
],
|
||||
}),
|
||||
...(wrong.length ? [el("p", { className: "m02-hopyo__warn", text: wrong.join("\n") })] : []),
|
||||
search,
|
||||
grid(["호표", "품명", "규격", "단위", "엑셀 단가", "제안 / 줄"], body),
|
||||
);
|
||||
}
|
||||
|
||||
const dropButton = (file: HopyoView): HTMLButtonElement =>
|
||||
createButton({
|
||||
label: "지우기",
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: async () => {
|
||||
const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 별칭 장부는 그대로`;
|
||||
if (!(await showConfirmDialog(ask, "지우기"))) return;
|
||||
try {
|
||||
await deleteHopyo(opt.scope, key);
|
||||
showToast("지움", "success");
|
||||
opt.onDeleted();
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/* ── 호표 하나 — 확인 창 ────────────────────────────────────── */
|
||||
function drawOne(no: number): void {
|
||||
nav += 1; // 파일 표 늦은 응답 버림
|
||||
view.replaceChildren(
|
||||
buildHopyoCheck({
|
||||
scope: opt.scope,
|
||||
key,
|
||||
no,
|
||||
canWrite: opt.canWrite,
|
||||
onBack: () => void drawFile(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void drawFile();
|
||||
return view;
|
||||
}
|
||||
@@ -265,6 +265,8 @@ def load_design_route(
|
||||
project_root: Path,
|
||||
surface_params: dict[str, Any] | None = None,
|
||||
route_range: tuple[float | None, float | None] | None = None,
|
||||
*,
|
||||
from_source: bool = False,
|
||||
) -> PlannedRoute | None:
|
||||
"""설계가 쓸 계획노선 한 벌을 만든다 — 읽기·좌표계 변환·트림·조밀화를 여기서 끝낸다.
|
||||
|
||||
@@ -279,6 +281,9 @@ def load_design_route(
|
||||
|
||||
`route_range`(시작·종료 누가거리 m)를 주면 **서피스 트림보다 먼저** 그 구간만 남긴다
|
||||
(2026-09-04 사용자 지시). 순서가 바뀌면 사용자가 정한 시점이 서피스 트림에 밀린다.
|
||||
|
||||
`from_source` 면 CSV 정본을 건너뛰고 올린 원본을 다시 읽는다 — 사용 범위를 바꾼 뒤
|
||||
예상노선을 다시 쓸 때(351-1).
|
||||
"""
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
|
||||
from common_util.common_util_initial_snapshot import design_route_csv_path
|
||||
@@ -293,7 +298,7 @@ def load_design_route(
|
||||
# 지표면·노선이 바뀌면 `discard_initial_snapshot()`이 폴더째 지우므로 이 경로가 저절로
|
||||
# 닫히고 원본 재판독으로 되돌아간다. 트림 **전** 원본이 필요한 호출(도엽 범위 —
|
||||
# surface_params 없음)은 여기를 타지 않는다.
|
||||
if surface_params:
|
||||
if surface_params and not from_source:
|
||||
# 읽는 순서 — 수정본 → **초기 폴리라인** → 예상노선(점 묶음) → 초기값 스냅샷.
|
||||
# 초기 폴리라인이 예상노선보다 앞선다: 예상노선은 점 묶음이라 그대로 이으면
|
||||
# 규칙 없는 선이 된다(2026-09-06 사용자 지시).
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/* 355-1 종단 ▲▼ 이웃 꺾임 축 돌림 단위검증 헬퍼 — `B05_Profile_UI_Profile_Alignment.ts` ·
|
||||
* `_Straighten.ts` 를 그 자리에서 트랜스파일해 세 경우를 Node 로 돌린다.
|
||||
* ① 처음 계획선 ▲ = 옛 값 ② 직선화 뒤 꺾임 ▲ = 사이 측점 한 직선 · 축 표고 그대로 · +0.1
|
||||
* ③ 직선 안쪽 ▲ = 옛 값. 옛 값 = 355-1 앞 adjustStation(아래 oldAdjust) 그대로.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const LOGIC = path.join(ROOT, "B05_Profile", "logic");
|
||||
const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts"));
|
||||
const straight = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Straighten.ts"));
|
||||
|
||||
// 전체 측점 폴리라인(20m 간격 · 0~200m) — 서버와 같이 꺾임마다 기본 L 라운드.
|
||||
const stations = Array.from({ length: 11 }, (_, i) => i * 20);
|
||||
const ground = stations.map((s, i) => 100 + 0.3 * i + (i % 2 ? 0.4 : 0));
|
||||
const base = {
|
||||
policy: {
|
||||
station_interval_m: 20,
|
||||
curve_radius_ratio: 1,
|
||||
curve_tangent_max_ratio: 0.5,
|
||||
curve_skip_legal_exception: false,
|
||||
default_curve_length_m: 15,
|
||||
default_curve_radius_m: 300,
|
||||
balance_tolerance_percent: 10,
|
||||
edit_step_m: 0.1,
|
||||
grade_violation_policy: "warn",
|
||||
max_grade_pct: 14,
|
||||
curve_skip_delta_pct: 0,
|
||||
paved: true,
|
||||
},
|
||||
onlyExplicitCurves: false,
|
||||
basePvi: stations.map((s, i) => ({ chainage_m: s, elevation_m: ground[i] })),
|
||||
chainage: stations,
|
||||
ground,
|
||||
stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })),
|
||||
};
|
||||
|
||||
/** 355-1 앞 adjustStation — 누른 측점 하나만 옮김. */
|
||||
function oldAdjust(edits, chainageM, delta) {
|
||||
const key = align.chainageKey(chainageM);
|
||||
const current = align.buildAlignment(base, edits);
|
||||
const target = align.planElevationAt(current, chainageM) + delta;
|
||||
const baseElevation = straight.baseElevationAt(base, chainageM);
|
||||
let offset =
|
||||
(edits.station_offsets[key] ?? align.planElevationAt(current, chainageM) - baseElevation) +
|
||||
delta;
|
||||
const withOffset = (value) => ({
|
||||
...edits,
|
||||
station_offsets: { ...edits.station_offsets, [key]: Number(value.toFixed(6)) },
|
||||
});
|
||||
let next = withOffset(offset);
|
||||
for (let pass = 0; pass < 4; pass += 1) {
|
||||
const error = target - align.planElevationAt(align.buildAlignment(base, next), chainageM);
|
||||
if (Math.abs(error) < 1e-4) break;
|
||||
offset += error;
|
||||
next = withOffset(offset);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
const sorted = (record) =>
|
||||
Object.fromEntries(
|
||||
Object.keys(record ?? {})
|
||||
.sort()
|
||||
.map((key) => [key, record[key]]),
|
||||
);
|
||||
const pviZ = (built, s) => built.pvi.find((n) => Math.abs(n.chainage_m - s) < 1e-6).elevation_m;
|
||||
const out = {};
|
||||
|
||||
// ① 처음 계획선 — 마디마다 꺾임 → 축 = 누른 점 ±1 · 옛 값과 같음(▲ · ▼).
|
||||
{
|
||||
const empty = align.emptyEdits();
|
||||
const up = align.adjustStation(base, empty, 100, 0.1);
|
||||
const down = align.adjustStation(base, empty, 60, -0.1);
|
||||
out.initial = {
|
||||
sameUp:
|
||||
JSON.stringify(sorted(up.station_offsets)) ===
|
||||
JSON.stringify(sorted(oldAdjust(empty, 100, 0.1).station_offsets)),
|
||||
sameDown:
|
||||
JSON.stringify(sorted(down.station_offsets)) ===
|
||||
JSON.stringify(sorted(oldAdjust(empty, 60, -0.1).station_offsets)),
|
||||
upKeys: Object.keys(up.station_offsets),
|
||||
radiiKeys: Object.keys(up.curve_radii ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ② 40~100 · 100~160 직선화 → 100 = 꺾임 · 두 축 40 · 160 · ▲ 100.
|
||||
{
|
||||
let edits = straight.straightenBetween(base, align.emptyEdits(), 40, 100);
|
||||
edits = straight.straightenBetween(base, edits, 100, 160);
|
||||
const before = align.buildAlignment(base, edits);
|
||||
const next = align.adjustStation(base, edits, 100, 0.1);
|
||||
const after = align.buildAlignment(base, next);
|
||||
const p = pviZ(after, 100);
|
||||
const line = (s, aM, aZ) => aZ + ((p - aZ) * (s - aM)) / (100 - aM);
|
||||
const z40 = pviZ(before, 40);
|
||||
const z160 = pviZ(before, 160);
|
||||
out.kink = {
|
||||
planRise: align.planElevationAt(after, 100) - align.planElevationAt(before, 100),
|
||||
pivotRise: p - pviZ(before, 100),
|
||||
axisMove: [pviZ(after, 40) - z40, pviZ(after, 160) - z160],
|
||||
outsideMove: [20, 180].map((s) => pviZ(after, s) - pviZ(before, s)),
|
||||
innerResidual: [60, 80]
|
||||
.map((s) => pviZ(after, s) - line(s, 40, z40))
|
||||
.concat([120, 140].map((s) => pviZ(after, s) - line(s, 160, z160))),
|
||||
innerMoved: [60, 80, 120, 140].map((s) => pviZ(after, s) - pviZ(before, s)),
|
||||
curvesAt: after.curves.map((c) => c.chainage_m).sort((a, b) => a - b),
|
||||
oldWouldBreak: (() => {
|
||||
const old = align.buildAlignment(base, oldAdjust(edits, 100, 0.1));
|
||||
return [80, 120].map((s) => pviZ(old, s) - pviZ(before, s));
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
// ③ 직선 안쪽(80 · 40~100 직선 위) — 꺾임 아님 → 옛 값.
|
||||
{
|
||||
let edits = straight.straightenBetween(base, align.emptyEdits(), 40, 100);
|
||||
edits = straight.straightenBetween(base, edits, 100, 160);
|
||||
const next = align.adjustStation(base, edits, 80, 0.1);
|
||||
out.inside = {
|
||||
same:
|
||||
JSON.stringify(sorted(next.station_offsets)) ===
|
||||
JSON.stringify(sorted(oldAdjust(edits, 80, 0.1).station_offsets)),
|
||||
planRise:
|
||||
align.planElevationAt(align.buildAlignment(base, next), 80) -
|
||||
align.planElevationAt(align.buildAlignment(base, edits), 80),
|
||||
};
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -185,6 +185,12 @@ let out0 = null;
|
||||
{ 번: 7, 아래: [8] },
|
||||
{ 번: 8, 아래: [7] },
|
||||
]),
|
||||
linked: [
|
||||
[2, 2],
|
||||
[1, 2],
|
||||
[0, 0],
|
||||
[3, 2],
|
||||
].map(sheet.allLinked),
|
||||
},
|
||||
};
|
||||
// 길 — 만들기(줄 · 통째) · 호표 하나(만들 층) · 미리 셈 · 장부
|
||||
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -136,6 +137,9 @@ def ts_alignment(tmp_path_factory):
|
||||
capture_output=True,
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
for js in out.glob("*.js"): # 355-1 Alignment 이 Pivot 을 부름 — Node ESM 은 확장자 필요
|
||||
text = js.read_text(encoding="utf-8")
|
||||
js.write_text(re.sub(r'(from "\./\w+)"', r'\1.js"', text), encoding="utf-8")
|
||||
data = _synthetic()
|
||||
base = {
|
||||
"policy": AlignmentPolicy.from_dict({}).as_dict(),
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -163,6 +164,9 @@ def test_followed_station_ts(tmp_path):
|
||||
capture_output=True,
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
for js in out.glob("*.js"): # 355-1 Alignment 이 Pivot 을 부름 — Node ESM 은 확장자 필요
|
||||
text = js.read_text(encoding="utf-8")
|
||||
js.write_text(re.sub(r'(from "\./\w+)"', r'\1.js"', text), encoding="utf-8")
|
||||
src, dst = tmp_path / "in.json", tmp_path / "out.json"
|
||||
src.write_text(json.dumps([case for case, _ in CASES]), encoding="utf-8")
|
||||
subprocess.run( # noqa: S603
|
||||
|
||||
@@ -297,8 +297,11 @@ def test_추천_저장본_종류당_최근_두_벌(tmp_path: Path) -> None:
|
||||
assert read_stored(tmp_path, "a") is not None # 꺼내면 최근으로
|
||||
time.sleep(0.02)
|
||||
write_stored(tmp_path, "c", "p", request, {"n": 3}, False)
|
||||
time.sleep(0.02)
|
||||
write_stored(tmp_path, "d", "p", request, {"n": 4}, False)
|
||||
names = sorted(path.stem for path in store_dir(tmp_path).glob("*.json"))
|
||||
assert names == ["a", "c", "g"]
|
||||
# 기본값(a)은 따로 셈 — 사용자 시도 b · c · d 중 옛 b 만 지움(351-1)
|
||||
assert names == ["a", "c", "d", "g"]
|
||||
|
||||
|
||||
# ── 횡단 상세 — GET 은 안 지움 · ETag(R26) ───────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""351-1 노선 추천 저장 빈틈 — 재확정 체인 · 기본값 지킴 · 저장된 제약 · B01 사용 범위.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_351_1_recommend_gaps.py -q
|
||||
|
||||
체인 뒤 첫 누름 = 저장본(기본값 · 저장된 제약 둘 다) · 「최근 2벌」 정리가 기본값을 안 밀어냄 ·
|
||||
사용 범위를 바꾸면 예상노선을 원본에서 다시 자르고 미리 셈.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from B05_Profile import B05_Profile_Router_RouteInitial as router
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import write_initial_choice
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import (
|
||||
DEFAULT_MODES,
|
||||
is_default,
|
||||
store_dir,
|
||||
write_stored,
|
||||
)
|
||||
from common_util.common_util_route_geometry import (
|
||||
expected_route_csv_path,
|
||||
load_design_route,
|
||||
planned_route_working_path,
|
||||
write_route_csv,
|
||||
)
|
||||
from test_224_3_recommend_store import _ask, wired # noqa: F401 — 픽스처
|
||||
|
||||
|
||||
def _saved(root: Path) -> list[dict]:
|
||||
return [json.loads(p.read_text(encoding="utf-8")) for p in store_dir(root).glob("*.json")]
|
||||
|
||||
|
||||
def test_저장된_제약으로도_미리_셈_첫_누름_저장본(wired) -> None: # noqa: F811
|
||||
criteria = {"min_radius_m": 15.0, "max_grade_pct": 12.0}
|
||||
write_initial_choice(
|
||||
wired.root, "weighted", {"expected": 40, "earthwork": 30, "grade": 30}, criteria
|
||||
)
|
||||
asyncio.run(router.precompute_defaults(uuid4()))
|
||||
assert wired.count == 2 * len(DEFAULT_MODES)
|
||||
assert all(item["default"] for item in _saved(wired.root))
|
||||
# 모달은 저장된 제약 · 비중으로 열림 — 셈 없이 저장본
|
||||
hit = _ask(
|
||||
mode="weighted", weights={"expected": 40, "earthwork": 30, "grade": 30}, criteria=criteria
|
||||
)
|
||||
gentle = _ask(mode="gentle", criteria=criteria)
|
||||
plain = _ask(mode="earthwork")
|
||||
assert hit["stored"] and gentle["stored"] and plain["stored"]
|
||||
assert wired.count == 10
|
||||
# 다시 불러도 셈 0
|
||||
asyncio.run(router.precompute_defaults(uuid4()))
|
||||
assert wired.count == 10
|
||||
|
||||
|
||||
def test_저장된_제약_없으면_기본값만(wired) -> None: # noqa: F811
|
||||
write_initial_choice(wired.root, "gentle", None, None)
|
||||
asyncio.run(router.precompute_defaults(uuid4()))
|
||||
assert wired.count == len(DEFAULT_MODES)
|
||||
|
||||
|
||||
def test_기본값은_최근_두_벌_정리에서_안_밀림(tmp_path: Path) -> None:
|
||||
request = {"mode": "earthwork", "terrain": None}
|
||||
write_stored(tmp_path, "d", "p", request, {}, True)
|
||||
for name in ("a", "b", "c"):
|
||||
time.sleep(0.02)
|
||||
write_stored(tmp_path, name, "p", request, {}, False)
|
||||
names = sorted(path.stem for path in store_dir(tmp_path).glob("*.json"))
|
||||
assert names == ["b", "c", "d"]
|
||||
|
||||
|
||||
def test_기본값_판정() -> None:
|
||||
saved = {
|
||||
"criteria": {"min_radius_m": 15.0},
|
||||
"weights": {"expected": 40.0, "earthwork": 30.0, "grade": 30.0},
|
||||
}
|
||||
assert is_default("gentle", None, None, None)
|
||||
assert is_default("gentle", None, {"min_radius_m": None}, None)
|
||||
assert not is_default("gentle", None, None, "las")
|
||||
assert not is_default("gentle", None, {"min_radius_m": 15.0}, None)
|
||||
assert is_default("gentle", None, {"min_radius_m": 15.0}, None, saved)
|
||||
assert not is_default("gentle", None, {"min_radius_m": 16.0}, None, saved)
|
||||
weights = {"expected": 40.0, "earthwork": 30.0, "grade": 30.0}
|
||||
assert is_default("weighted", weights, {"min_radius_m": 15.0}, None, saved)
|
||||
assert not is_default("weighted", None, {"min_radius_m": 15.0}, None, saved)
|
||||
|
||||
|
||||
def test_두_체인_끝에서_미리_셈_예약() -> None:
|
||||
source = Path("B03_FileInput/B03_FileInput_Service_Chain.py").read_text(encoding="utf-8")
|
||||
redesign = source[source.index("async def run_redesign_chain(") :]
|
||||
upload = source[: source.index("async def run_redesign_chain(")]
|
||||
assert "schedule_precompute(project_id)" in upload
|
||||
# 재확정 체인 성공 끝(완료 로그 앞)
|
||||
done = redesign.index("재확정 체인 완료")
|
||||
assert "schedule_precompute(project_id)" in redesign[:done]
|
||||
b01 = Path("B01_Dashboard/B01_Dashboard_Router.py").read_text(encoding="utf-8")
|
||||
assert "refresh_expected_route(" in b01
|
||||
|
||||
|
||||
def test_사용_범위_비교() -> None:
|
||||
from decimal import Decimal
|
||||
|
||||
from B01_Dashboard.B01_Dashboard_Router import _range_of
|
||||
|
||||
assert _range_of({"route_start_m": Decimal("10.0"), "route_end_m": None}) == (10.0, None)
|
||||
assert _range_of({"route_start_m": 10.0, "route_end_m": None}) == _range_of(
|
||||
{"route_start_m": Decimal("10"), "route_end_m": None}
|
||||
)
|
||||
assert _range_of({"route_start_m": 10.0}) != _range_of({"route_start_m": 20.0})
|
||||
|
||||
|
||||
def _source_project(root: Path) -> None:
|
||||
write_route_csv(
|
||||
root / "B03_FileInput/input/route.csv",
|
||||
[{"x": float(x), "y": 0.0} for x in range(0, 101, 10)],
|
||||
)
|
||||
# 설계 정본(수정본) · 예상노선이 따로 있음 — 원본에서 다시 읽어야 함
|
||||
write_route_csv(planned_route_working_path(root), [{"x": 0.0, "y": 5.0}, {"x": 9.0, "y": 5.0}])
|
||||
write_route_csv(expected_route_csv_path(root), [{"x": 0.0, "y": 0.0}, {"x": 100.0, "y": 0.0}])
|
||||
|
||||
|
||||
def test_원본에서_다시_읽기(tmp_path: Path) -> None:
|
||||
_source_project(tmp_path)
|
||||
surface = {"source_filter": "csf", "method": "dtm", "smooth": False}
|
||||
stored = load_design_route(tmp_path, surface, (20.0, 80.0))
|
||||
assert [v.y for v in stored.vertices] == [5.0, 5.0] # 정본이 먼저
|
||||
fresh = load_design_route(tmp_path, surface, (20.0, 80.0), from_source=True)
|
||||
xs = [round(v.x, 3) for v in fresh.vertices]
|
||||
assert xs[0] == 20.0 and xs[-1] == 80.0
|
||||
|
||||
|
||||
def test_사용_범위_바꾸면_예상노선_다시_쓰고_미리_셈(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
import B03_FileInput.B03_FileInput_Repository as repo
|
||||
import common_util.common_util_storage as storage
|
||||
import common_util.common_util_surface_confirmation as surface
|
||||
|
||||
_source_project(tmp_path)
|
||||
calls: list[str] = []
|
||||
|
||||
class _Cursor:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return None
|
||||
|
||||
async def execute(self, *_):
|
||||
return None
|
||||
|
||||
async def fetchone(self):
|
||||
return (30.0, 70.0)
|
||||
|
||||
class _Connection:
|
||||
def cursor(self):
|
||||
return _Cursor()
|
||||
|
||||
class _Acquire:
|
||||
async def __aenter__(self):
|
||||
return _Connection()
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return None
|
||||
|
||||
class _Pool:
|
||||
def acquire(self):
|
||||
return _Acquire()
|
||||
|
||||
async def storage_path(*_):
|
||||
return "x"
|
||||
|
||||
async def surface_params(*_):
|
||||
return {"source_filter": "csf", "method": "dtm", "smooth": False}
|
||||
|
||||
async def precompute(_pid):
|
||||
calls.append("precompute")
|
||||
|
||||
monkeypatch.setattr(router, "get_db_pool", lambda: _Pool())
|
||||
monkeypatch.setattr(repo, "get_project_storage_relative_path", storage_path)
|
||||
monkeypatch.setattr(storage, "resolve_stored_project_path", lambda _p: str(tmp_path))
|
||||
monkeypatch.setattr(surface, "get_surface_confirmation_params", surface_params)
|
||||
monkeypatch.setattr(router, "precompute_defaults", precompute)
|
||||
asyncio.run(router.refresh_expected_route(uuid4()))
|
||||
rows = expected_route_csv_path(tmp_path).read_text(encoding="utf-8").splitlines()
|
||||
assert rows[1].split(",")[1:] == ["30.0", "0.0"] and rows[-1].split(",")[1:] == ["70.0", "0.0"]
|
||||
assert calls == ["precompute"]
|
||||
# 체인 전(예상노선 없음)은 건너뜀
|
||||
expected_route_csv_path(tmp_path).unlink()
|
||||
asyncio.run(router.refresh_expected_route(uuid4()))
|
||||
assert calls == ["precompute"] and not expected_route_csv_path(tmp_path).exists()
|
||||
@@ -0,0 +1,72 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""355-1 B05 종단 ▲▼ — 누른 점이 꺾임이면 이웃 꺾임을 축으로 사이 직선이 따라 돎(요구 235-15).
|
||||
|
||||
계산(`B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts` adjustStation ·
|
||||
`_Pivot.ts` pivotAxes)은 Node 헬퍼(`helper_355_1_pivot_tilt.cjs`)가 트랜스파일해 돌리고,
|
||||
부르는 곳 셋(▲▼ 버튼 · 표 값 입력 · 방향키)이 같은 함수인지는 소스로 본다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent.parent
|
||||
|
||||
|
||||
def _read(relative: str) -> str:
|
||||
return (ROOT / relative).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_355_1_pivot_tilt.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_처음_계획선은_옛_값(out):
|
||||
initial = out["initial"]
|
||||
assert initial["sameUp"] and initial["sameDown"]
|
||||
assert initial["upKeys"] == ["100.000"]
|
||||
assert initial["radiiKeys"] == []
|
||||
|
||||
|
||||
def test_꺾임_누르면_사이_측점이_한_직선(out):
|
||||
kink = out["kink"]
|
||||
assert kink["planRise"] == pytest.approx(0.1, abs=1e-4)
|
||||
assert kink["axisMove"] == [0, 0]
|
||||
assert kink["outsideMove"] == [0, 0]
|
||||
assert all(abs(value) < 1e-6 for value in kink["innerResidual"])
|
||||
# 축에서 멀수록 더 오름(40 → 60 → 80 → 100 · 160 → 140 → 120 → 100)
|
||||
moved = kink["innerMoved"]
|
||||
assert 0 < moved[0] < moved[1] < kink["pivotRise"]
|
||||
assert 0 < moved[3] < moved[2] < kink["pivotRise"]
|
||||
# 옛 길은 사이 측점이 그대로라 P±1 에서 꺾였음
|
||||
assert kink["oldWouldBreak"] == [0, 0]
|
||||
|
||||
|
||||
def test_직선_안쪽은_옛_값(out):
|
||||
assert out["inside"]["same"]
|
||||
assert out["inside"]["planRise"] == pytest.approx(0.1, abs=1e-4)
|
||||
|
||||
|
||||
def test_부르는_곳_셋이_같은_함수():
|
||||
assert "adjustStation(base, store.edits(), chainage, delta)" in _read(
|
||||
"B05_Profile/B05_Profile_UI_Profile_Render.ts"
|
||||
)
|
||||
assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in _read(
|
||||
"B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts"
|
||||
)
|
||||
assert "pivotAxes(current, chainageM)" in _read(
|
||||
"B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts"
|
||||
)
|
||||
@@ -24,7 +24,6 @@ NEW = (
|
||||
"Hopyo_Api",
|
||||
"Hopyo_Pick",
|
||||
"Hopyo_Side",
|
||||
"Hopyo_View",
|
||||
"Hopyo_Sheet",
|
||||
"Hopyo_Check",
|
||||
"Hopyo_Choose",
|
||||
@@ -192,7 +191,9 @@ def test_로직_추가_확인된_것은_바로_참조_아니면_확인_창():
|
||||
|
||||
|
||||
def test_엑셀_보기_창_하위_호표_먼저():
|
||||
assert _check()["sheet"]["order"] == [46, 45, 3, 7, 8] # 순환은 남은 차례
|
||||
got = _check()["sheet"]
|
||||
assert got["order"] == [46, 45, 3, 7, 8] # 순환은 남은 차례
|
||||
assert got["linked"] == [True, False, False, False] # 다 연결만 녹색 · 0/0 색 없음(359-2)
|
||||
|
||||
|
||||
def _pick() -> dict:
|
||||
@@ -279,18 +280,27 @@ def test_옛_즉시_확정과_바로_만들기_없음():
|
||||
):
|
||||
assert label not in every, label
|
||||
users = sorted(name for name, text in texts.items() if "buildHopyo(" in text)
|
||||
assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 펼침 뒤 한 번
|
||||
assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 엑셀 보기 [저장] 물음 뒤
|
||||
check = texts["Hopyo_Check"]
|
||||
assert '"확인하고 사전조합 만들기"' in check and "맞음 · 별칭 모두 체크" in check
|
||||
assert "showLoadingOverlay()" in check and "dropDraft(k)" in check
|
||||
assert "m02-hopyo-check:" in check and "POLL_MS" in check
|
||||
sheet = texts["Hopyo_Sheet"]
|
||||
assert "확인하고 만들기" in sheet and "이대로 ${ready.length}개 만들기" in sheet
|
||||
assert "확인 창 열기" in sheet and "buildOrder(" in sheet
|
||||
# 359-2 — 펼침 표 · [확인 창 열기] · [이대로 n개 만들기] 없음 · [저장] = 물음 한 번 뒤 체크 줄로 만들기
|
||||
for gone in ("확인 창 열기", "이대로", "review(", "확인하고 만들기"):
|
||||
assert gone not in sheet, gone
|
||||
save = sheet.split("async function save(")[1].split("async function makeAll(")[0]
|
||||
assert 'showConfirmDialog(ask, "저장")' in save and "hasDraft(" in save
|
||||
assert "건너뜀 — 체크 줄 없음" in save and "await makeAll(ready, skipped);" in save
|
||||
assert "buildOrder(" in sheet and "error instanceof StaleError" in sheet # 하위 먼저 · 409 안내
|
||||
edit = sheet.split("const editButton =")[1].split("function paintList(")[0]
|
||||
assert '"수정"' in edit and "e.stopPropagation();" in edit and "openHopyoCheck({" in edit
|
||||
row = sheet.split("function paintList(")[1]
|
||||
assert row.index("m02-hopyo-sheet__no") < row.index("editButton(h)") < row.index("__name")
|
||||
assert 'linked.classList.toggle("is-linked", allLinked(h.맞춤));' in row
|
||||
pick = texts["Hopyo_Pick"]
|
||||
assert "if (row.확인)" in pick and 'import("./M02_MasterTemplete_Hopyo_Check")' in pick
|
||||
view = texts["Hopyo_View"]
|
||||
assert "buildHopyoCheck(" in view and "choose(" not in view
|
||||
assert not (M02 / "M02_MasterTemplete_Hopyo_View.ts").exists() # 본 칸 표 없음(359-1)
|
||||
ledger = texts["Hopyo_Ledger"]
|
||||
assert "m02-hopyo-ledger:" in ledger and "saveLedger(" in ledger
|
||||
for gone in ("발주처", "회사로 올리기", "쓰인 횟수"):
|
||||
@@ -306,6 +316,13 @@ def test_좌측_판_자리와_권한():
|
||||
assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한
|
||||
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
|
||||
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
# 359-1 — [파일 업로드] = 구조물 조합 층 단추 꼴(보통 크기 · m02-layers__btn) · 파일 줄 = 바로 엑셀 보기
|
||||
up = hopyo_side.split("const upload = createButton({")[1].split("upload.title")[0]
|
||||
assert 'label: "파일 업로드"' in up and 'upload.classList.add("m02-layers__btn");' in up
|
||||
assert "size:" not in up and "엑셀 올리기" not in hopyo_side
|
||||
assert "buildHopyoView" not in hopyo_side
|
||||
assert 'row.addEventListener("click", () => void showSheet(f));' in hopyo_side
|
||||
assert "onClose:" in hopyo_side and "onDeleted: () => void refresh()," in hopyo_side
|
||||
assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side
|
||||
assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side
|
||||
assert "buildHopyoLedger({ scope, canWrite: opt.canWrite })" in hopyo_side # [별칭 장부]
|
||||
@@ -332,16 +349,15 @@ def test_로직_추가_한_곳과_파일_규칙():
|
||||
def test_엑셀_보기_창_여는_자리와_늦게_불러옴():
|
||||
lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")'
|
||||
side = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
assert lazy in side.split("await show(got);")[1].split("})")[0] # 올린 직후 자동
|
||||
view = _text("M02_MasterTemplete_Hopyo_View.ts")
|
||||
assert lazy in view and '"엑셀로 보기"' in view
|
||||
assert "await showSheet(got);" in side # 올린 직후 자동
|
||||
assert lazy in side.split("async function showSheet(")[1].split("async function showLedger(")[0]
|
||||
check = _text("M02_MasterTemplete_Hopyo_Check.ts")
|
||||
assert lazy in check and '"엑셀에서 보기"' in check and "호표: opt.no" in check
|
||||
assert "단산행: 행" in check
|
||||
pick = _text("M02_MasterTemplete_Hopyo_Pick.ts")
|
||||
assert lazy in pick
|
||||
static = r'^import (?!type )[^(;]*"\./M02_MasterTemplete_Hopyo_Sheet"'
|
||||
for text in (side, view, pick, check):
|
||||
for text in (side, pick, check):
|
||||
assert not re.search(static, text, re.M) # 정적 import 없음(타입만)
|
||||
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
|
||||
assert 'import("../A00_Common/spreadsheet/spreadsheet")' in sheet
|
||||
@@ -400,15 +416,18 @@ def test_확인_창_손질_339():
|
||||
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
|
||||
assert "poll(file);" in sheet and "poll(next);" in sheet and "POLL_MS" in sheet
|
||||
assert "if (ok) document.dispatchEvent(new Event(HOPYO_BUILT));" in sheet
|
||||
assert '`맞음 ${tally("맞음")} · 다른 점 ${tally("다른 점")}`' in sheet
|
||||
|
||||
|
||||
def test_본_칸_호표_목록_셈_끝까지_다시_읽음_339():
|
||||
"""339 덧고침 2 — 본 칸 호표 표 「제안 n / 줄 m」 · 머리 「셈 중」 — 셈 중이면 3 초마다 다시 읽음."""
|
||||
view = _text("M02_MasterTemplete_Hopyo_View.ts")
|
||||
assert "poll(file);" in view and "poll(next);" in view and "POLL_MS" in view
|
||||
assert "hopyos = next.호표;" in view and "...hopyos" in view
|
||||
assert (
|
||||
"const live = (): boolean => my === nav && view.isConnected;" in view
|
||||
) # 호표 하나로 가면 멈춤
|
||||
assert "숫자 판정 셈 중" in view
|
||||
def test_엑셀_보기_머리_막대_셈_끝까지_다시_읽음_359():
|
||||
"""339 덧고침 2 → 359-1 — 옛 본 칸 표 몫(시트 · 호표 수 · 검산 · 「제안 n / 줄 m」 · 「셈 중」 · [삭제])은
|
||||
엑셀 보기 창 머리 · 셈 중이면 3 초마다 다시 읽음(머리 · 목록 함께)."""
|
||||
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
|
||||
head = sheet.split("function paintHead(")[1].split("const dropButton")[0]
|
||||
for word in ("시트 ", "호표 ", "검산 어긋남", "검산 맞음", "제안 ", "숫자 판정 셈 중"):
|
||||
assert word in head, word
|
||||
assert "opt.canWrite && opt.onDeleted ? [dropButton()]" in head
|
||||
drop = sheet.split("const dropButton =")[1].split("/** 목록 강조")[0]
|
||||
assert 'label: "삭제"' in drop and 'showConfirmDialog(ask, "삭제")' in drop
|
||||
assert drop.index("deleteHopyo(") < drop.index("modal.close();") < drop.index("opt.onDeleted")
|
||||
poll = sheet.split("function poll(")[1].split("async function load(")[0]
|
||||
assert "paintHead(next);" in poll and "paintList();" in poll and "poll(next);" in poll
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""일위대가 실무자료 이음매(PLAN 329 · 330) — 실제 서버 응답(북부청 격자 · 호표 맞춤)을
|
||||
화면 도우미(`helper_m02_hopyo_seam.cjs` → Hopyo_Sheet · Hopyo_View)에 먹임.
|
||||
화면 도우미(`helper_m02_hopyo_seam.cjs` → Hopyo_Sheet)에 먹임.
|
||||
|
||||
행 22 → 제3호표 · 25 빈 줄 · 26 → 제4호표 · 영역 B20:O24 · 단산93 행 4229:4244 ·
|
||||
엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 · 도우미는 고르기 창 `Hopyo_Choose`.
|
||||
|
||||
Reference in New Issue
Block a user