Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -185,6 +185,8 @@ export const STATE_REGISTRY = {
|
||||
"b08-hydro": { bucket: "draft", scope: "project", version: 3 },
|
||||
/** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */
|
||||
"b08-options": { bucket: "draft", scope: "project" },
|
||||
/** B08 거리이정표 입력 `{site, base, routes}` 한 벌 — [저장] 때 프로젝트 B08_Quantity/milestone 으로(244-1). */
|
||||
"b08-milestone": { bucket: "draft", scope: "project" },
|
||||
/** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */
|
||||
"b09-estimation": { bucket: "draft", scope: "project" },
|
||||
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""B08 거리이정표 표 합(PLAN 244-1) — 화면 없음 · 저장 안 함.
|
||||
|
||||
⚠ 짝 = `B08_Quantity_Milestone_Calc.ts`(같은 식) — 한쪽을 고치면 다른 쪽도 ·
|
||||
거울 시험 `resources/tester/test_244_1_b08_milestone.py`.
|
||||
조작 중은 TS 짝이 바로 셈 · [저장] · 읽기 때 서버가 이 파일로 다시 셈.
|
||||
|
||||
표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면
|
||||
시점 → 중점 = 중점 측점 km) · 도로 구분 셋(2차포장 · 1차포장 · 비포장)마다 합 ·
|
||||
포장 = 2차 + 1차 · 비포장 · 합계 = 포장 + 비포장(견본 「거리이정표」 시트 수식) ·
|
||||
끝수 = 소수 셋째 자리(반올림).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
ROADS = ("2차포장", "1차포장", "비포장")
|
||||
KEYS = {"2차포장": "s2", "1차포장": "s1", "비포장": "un"}
|
||||
|
||||
|
||||
def r3(value: float) -> float:
|
||||
"""소수 셋째 자리 반올림(0.5 올림 · TS `Math.round` 와 같음)."""
|
||||
return math.floor(value * 1000 + 0.5) / 1000
|
||||
|
||||
|
||||
def add_legs(sums: dict[str, float], legs: list[dict[str, Any]] | None) -> None:
|
||||
for leg in legs or []:
|
||||
key = KEYS.get(leg.get("road"))
|
||||
if key:
|
||||
sums[key] += float(leg.get("km") or 0)
|
||||
|
||||
|
||||
def mid_km(data: dict[str, Any], mid_default_m: float | None) -> float:
|
||||
"""시점 → 중점 km — 사용자 측점(없으면 기본 = 노선 ÷ 2) ÷ 1000."""
|
||||
station = (data.get("site") or {}).get("mid_station_m")
|
||||
if station is None:
|
||||
station = mid_default_m
|
||||
return float(station or 0) / 1000
|
||||
|
||||
|
||||
def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list[dict[str, Any]]:
|
||||
"""표 줄 — 경로 차례 · 경로 안 줄 차례."""
|
||||
site = data.get("site") or {}
|
||||
base = data.get("base") or {}
|
||||
mid = {"road": site.get("mid_road") or "비포장", "km": mid_km(data, mid_default_m)}
|
||||
out: list[dict[str, Any]] = []
|
||||
for route in data.get("routes") or []:
|
||||
for index, row in enumerate(route.get("rows") or []):
|
||||
sums = {"s2": 0.0, "s1": 0.0, "un": 0.0}
|
||||
add_legs(sums, route.get("legs"))
|
||||
if base.get("enabled") and route.get("via_base", True):
|
||||
add_legs(sums, base.get("legs"))
|
||||
if row.get("dest") == "mid":
|
||||
add_legs(sums, [mid])
|
||||
paved = sums["s2"] + sums["s1"]
|
||||
out.append(
|
||||
{
|
||||
"route": route.get("id"),
|
||||
"index": index,
|
||||
"item": row.get("item") or "",
|
||||
"company": route.get("name") or "",
|
||||
"dest": row.get("dest") or "start",
|
||||
"s2": r3(sums["s2"]),
|
||||
"s1": r3(sums["s1"]),
|
||||
"un": r3(sums["un"]),
|
||||
"paved": r3(paved),
|
||||
"unpaved": r3(sums["un"]),
|
||||
"total": r3(paved + sums["un"]),
|
||||
"note": row.get("note") or "",
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,127 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_Milestone_Calc.ts
|
||||
* B08 거리이정표 표 합(PLAN 244-1) — 화면 없음 · 저장 안 함 · import 없음(시험이 node 로 돌림).
|
||||
*
|
||||
* ⚠ 짝 = `B08_Quantity_Milestone_Calc.py`(같은 식) — 한쪽을 고치면 다른 쪽도 ·
|
||||
* 거울 시험 `resources/tester/test_244_1_b08_milestone.py`.
|
||||
* 조작 중은 이 파일이 바로 셈 · [저장] · 읽기 때 서버가 파이썬 짝으로 다시 셈.
|
||||
*
|
||||
* 표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면
|
||||
* 시점 → 중점 = 중점 측점 km) · 도로 구분 셋마다 합 · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장 ·
|
||||
* 끝수 = 소수 셋째 자리(반올림)
|
||||
* ========================================================================== */
|
||||
|
||||
export const ROADS = ["2차포장", "1차포장", "비포장"] as const;
|
||||
export type Road = (typeof ROADS)[number];
|
||||
/** 8방 — 기준점(없으면 공사지) 기준 지도 방위 */
|
||||
export const DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const;
|
||||
export type Dir = (typeof DIRS)[number];
|
||||
export type Dest = "start" | "mid";
|
||||
|
||||
export interface Leg {
|
||||
road: Road;
|
||||
km: number | null;
|
||||
}
|
||||
|
||||
export interface MilestoneRow {
|
||||
item: string;
|
||||
dest: Dest;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface MilestoneRoute {
|
||||
id: string;
|
||||
/** 업체 이름 = 하위 탭 이름 */
|
||||
name: string;
|
||||
address: string;
|
||||
/** 기준점 거침(기준점 없으면 무시 · 시점으로 바로) */
|
||||
via_base: boolean;
|
||||
dir: Dir | null;
|
||||
lon?: number | null;
|
||||
lat?: number | null;
|
||||
/** 업체 → 기준점(거침) · 시점 구간 */
|
||||
legs: Leg[];
|
||||
rows: MilestoneRow[];
|
||||
}
|
||||
|
||||
export interface MilestoneData {
|
||||
site: { mid_station_m: number | null; mid_road: Road };
|
||||
base: {
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
address: string;
|
||||
dir: Dir | null;
|
||||
lon?: number | null;
|
||||
lat?: number | null;
|
||||
/** 기준점 → 시점 공통 구간 */
|
||||
legs: Leg[];
|
||||
};
|
||||
routes: MilestoneRoute[];
|
||||
}
|
||||
|
||||
export interface MilestoneTableRow {
|
||||
route: string;
|
||||
index: number;
|
||||
item: string;
|
||||
company: string;
|
||||
dest: Dest;
|
||||
s2: number;
|
||||
s1: number;
|
||||
un: number;
|
||||
paved: number;
|
||||
unpaved: number;
|
||||
total: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
const KEYS: Record<string, "s2" | "s1" | "un"> = { "2차포장": "s2", "1차포장": "s1", 비포장: "un" };
|
||||
|
||||
/** 소수 셋째 자리 반올림(파이썬 짝 floor(x × 1000 + 0.5) 과 같음) */
|
||||
export const r3 = (value: number): number => Math.floor(value * 1000 + 0.5) / 1000;
|
||||
|
||||
function addLegs(sums: Record<"s2" | "s1" | "un", number>, legs: Leg[] | undefined): void {
|
||||
for (const leg of legs ?? []) {
|
||||
const key = KEYS[leg.road];
|
||||
if (key) sums[key] += Number(leg.km ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** 시점 → 중점 km — 사용자 측점(없으면 기본 = 노선 ÷ 2) ÷ 1000 */
|
||||
export function midKm(data: MilestoneData, midDefaultM: number | null): number {
|
||||
const station = data.site?.mid_station_m ?? midDefaultM;
|
||||
return Number(station ?? 0) / 1000;
|
||||
}
|
||||
|
||||
/** 표 줄 — 경로 차례 · 경로 안 줄 차례 */
|
||||
export function computeMilestone(
|
||||
data: MilestoneData,
|
||||
midDefaultM: number | null,
|
||||
): MilestoneTableRow[] {
|
||||
const base = data.base;
|
||||
const mid: Leg = { road: data.site?.mid_road ?? "비포장", km: midKm(data, midDefaultM) };
|
||||
const out: MilestoneTableRow[] = [];
|
||||
for (const route of data.routes ?? []) {
|
||||
(route.rows ?? []).forEach((row, index) => {
|
||||
const sums = { s2: 0, s1: 0, un: 0 };
|
||||
addLegs(sums, route.legs);
|
||||
if (base?.enabled && route.via_base !== false) addLegs(sums, base.legs);
|
||||
if (row.dest === "mid") addLegs(sums, [mid]);
|
||||
const paved = sums.s2 + sums.s1;
|
||||
out.push({
|
||||
route: route.id,
|
||||
index,
|
||||
item: row.item ?? "",
|
||||
company: route.name ?? "",
|
||||
dest: row.dest ?? "start",
|
||||
s2: r3(sums.s2),
|
||||
s1: r3(sums.s1),
|
||||
un: r3(sums.un),
|
||||
paved: r3(paved),
|
||||
unpaved: r3(sums.un),
|
||||
total: r3(paved + sums.un),
|
||||
note: row.note ?? "",
|
||||
});
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
"""B08 거리이정표 길(PLAN 244-1) — 공사지 · 기준점 · 경로(업체) · 표 · 경로 그림. DB 표 없음.
|
||||
|
||||
GET /api/projects/{id}/quantity/milestone {data, site, table, images}
|
||||
PUT /api/projects/{id}/quantity/milestone [저장] — 입력만 · 표는 서버가 셈
|
||||
POST /api/projects/{id}/quantity/milestone/image?route= 경로 그림 올리기(하나 · 교체)
|
||||
GET /api/projects/{id}/quantity/milestone/image?route= 그림 내려받기
|
||||
DELETE /api/projects/{id}/quantity/milestone/image?route= 그림 지우기
|
||||
|
||||
정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 그림 ·
|
||||
폴더가 목록 · B09 견적서 파일 길 꼴). 초기값 층 없음(자동설계가 낳는 값 아님).
|
||||
그림은 올리는 즉시 서버에([저장]과 따로) · [저장] 때 없는 경로의 그림은 지움.
|
||||
공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례
|
||||
(수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례).
|
||||
표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field, ValidationError, model_validator
|
||||
|
||||
from B03_FileInput.B03_FileInput_Engine import save_upload_stream
|
||||
from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3
|
||||
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||||
from common_util.common_util_initial_snapshot import design_route_csv_path
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_route_geometry import (
|
||||
expected_route_csv_path,
|
||||
interpolate_vertex,
|
||||
planned_route_initial_path,
|
||||
planned_route_working_path,
|
||||
read_planned_route_csv,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B08 Milestone"])
|
||||
PATH = "/{project_id}/quantity/milestone"
|
||||
FOLDER = Path("B08_Quantity") / "milestone"
|
||||
DATA_FILE = "milestone.json"
|
||||
IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
|
||||
ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$"
|
||||
|
||||
Road = Literal["2차포장", "1차포장", "비포장"]
|
||||
Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
|
||||
|
||||
|
||||
class Leg(BaseModel):
|
||||
road: Road = "2차포장"
|
||||
km: float | None = Field(default=None, ge=0, le=10000)
|
||||
|
||||
|
||||
class Row(BaseModel):
|
||||
item: str = Field(default="", max_length=100)
|
||||
dest: Literal["start", "mid"] = "start"
|
||||
note: str = Field(default="", max_length=200)
|
||||
|
||||
|
||||
class Place(BaseModel):
|
||||
name: str = Field(default="", max_length=100)
|
||||
address: str = Field(default="", max_length=200)
|
||||
dir: Dir | None = None
|
||||
lon: float | None = Field(default=None, ge=-180, le=180)
|
||||
lat: float | None = Field(default=None, ge=-90, le=90)
|
||||
legs: list[Leg] = Field(default_factory=list, max_length=20)
|
||||
|
||||
|
||||
class Route(Place):
|
||||
id: str = Field(pattern=ROUTE_ID)
|
||||
via_base: bool = True
|
||||
rows: list[Row] = Field(default_factory=list, max_length=50)
|
||||
|
||||
|
||||
class Base(Place):
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class Site(BaseModel):
|
||||
mid_station_m: float | None = Field(default=None, ge=0, le=1_000_000)
|
||||
mid_road: Road = "비포장"
|
||||
|
||||
|
||||
class Milestone(BaseModel):
|
||||
site: Site = Field(default_factory=Site)
|
||||
base: Base = Field(default_factory=Base)
|
||||
routes: list[Route] = Field(default_factory=list, max_length=100)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _unique_ids(self) -> Milestone:
|
||||
ids = [route.id for route in self.routes]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError("경로 id 가 겹칩니다.")
|
||||
return self
|
||||
|
||||
|
||||
def folder(root: str | Path) -> Path:
|
||||
return Path(root) / FOLDER
|
||||
|
||||
|
||||
def read_data(root: str | Path) -> dict[str, Any]:
|
||||
"""저장본 — 없거나 깨졌으면 빈 꼴."""
|
||||
path = folder(root) / DATA_FILE
|
||||
try:
|
||||
return Milestone.model_validate_json(path.read_text(encoding="utf-8")).model_dump()
|
||||
except (OSError, ValidationError, json.JSONDecodeError):
|
||||
return Milestone().model_dump()
|
||||
|
||||
|
||||
def _route_vertices(root: Path) -> list[Any]:
|
||||
for path in (
|
||||
planned_route_working_path(root),
|
||||
planned_route_initial_path(root),
|
||||
expected_route_csv_path(root),
|
||||
design_route_csv_path(root),
|
||||
):
|
||||
route = read_planned_route_csv(path) if path.is_file() else None
|
||||
if route is not None and len(route.vertices) >= 2:
|
||||
return route.vertices
|
||||
return []
|
||||
|
||||
|
||||
def _lonlat(root: Path, x: float, y: float) -> list[float] | None:
|
||||
"""작업 좌표계 → 경도 · 위도(다이어그램 방위 · 244-3 지도) — 못 하면 None."""
|
||||
try:
|
||||
from pyproj import Transformer
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
|
||||
|
||||
crs = project_epsg_from_prj(root)
|
||||
lon, lat = Transformer.from_crs(crs, "EPSG:4326", always_xy=True).transform(x, y)
|
||||
return [round(lon, 7), round(lat, 7)]
|
||||
except Exception: # 좌표계 못 읽음 = 좌표 없음(방위는 사용자 8방)
|
||||
return None
|
||||
|
||||
|
||||
def site_info(root: str | Path, mid_station_m: float | None) -> dict[str, Any]:
|
||||
"""노선 연장 · 중점 기본(연장 ÷ 2) · 시점 · 중점 경위도."""
|
||||
root = Path(root)
|
||||
vertices = _route_vertices(root)
|
||||
if not vertices:
|
||||
return {"length_m": None, "mid_default_m": None, "start_lonlat": None, "mid_lonlat": None}
|
||||
length = vertices[-1].chainage_m
|
||||
mid_default = r3(length / 2)
|
||||
mx, my, _ = interpolate_vertex(
|
||||
vertices, mid_default if mid_station_m is None else mid_station_m
|
||||
)
|
||||
return {
|
||||
"length_m": r3(length),
|
||||
"mid_default_m": mid_default,
|
||||
"start_lonlat": _lonlat(root, vertices[0].x, vertices[0].y),
|
||||
"mid_lonlat": _lonlat(root, mx, my),
|
||||
}
|
||||
|
||||
|
||||
def route_images(root: str | Path, route_id: str) -> list[Path]:
|
||||
path = folder(root)
|
||||
if not path.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
f for f in path.glob(f"{route_id}.*") if f.is_file() and f.suffix.lower() in IMAGE_EXT
|
||||
)
|
||||
|
||||
|
||||
def images(root: str | Path) -> dict[str, str]:
|
||||
"""경로 id → 그림 파일 이름(폴더가 목록)."""
|
||||
path = folder(root)
|
||||
if not path.is_dir():
|
||||
return {}
|
||||
return {
|
||||
f.stem: f.name
|
||||
for f in sorted(path.iterdir())
|
||||
if f.is_file() and f.suffix.lower() in IMAGE_EXT and not f.name.startswith(".")
|
||||
}
|
||||
|
||||
|
||||
def payload(root: str | Path, data: dict[str, Any]) -> dict[str, Any]:
|
||||
site = site_info(root, data["site"]["mid_station_m"])
|
||||
return {
|
||||
"data": data,
|
||||
"site": site,
|
||||
"table": compute_milestone(data, site["mid_default_m"]),
|
||||
"images": images(root),
|
||||
}
|
||||
|
||||
|
||||
def save_data(root: str | Path, body: Milestone) -> dict[str, Any]:
|
||||
data = body.model_dump()
|
||||
path = folder(root)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(path / DATA_FILE, data)
|
||||
keep = {route["id"] for route in data["routes"]}
|
||||
for stem, name in images(root).items():
|
||||
if stem not in keep:
|
||||
(path / name).unlink(missing_ok=True)
|
||||
return payload(root, data)
|
||||
|
||||
|
||||
def _route_id(route: str) -> str:
|
||||
try:
|
||||
return Route(id=route).id
|
||||
except ValidationError as error:
|
||||
raise HTTPException(status_code=400, detail="경로 id 가 바르지 않습니다.") from error
|
||||
|
||||
|
||||
@router.get(PATH)
|
||||
async def milestone_read(project_id: UUID) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
return await asyncio.to_thread(lambda: payload(root, read_data(root)))
|
||||
|
||||
|
||||
@router.put(PATH)
|
||||
async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
return await asyncio.to_thread(save_data, root, body)
|
||||
|
||||
|
||||
@router.post(PATH + "/image")
|
||||
async def milestone_image_upload(
|
||||
project_id: UUID, route: str = Query(...), file: UploadFile = File(...)
|
||||
) -> dict[str, Any]:
|
||||
route_id = _route_id(route)
|
||||
ext = Path(file.filename or "").suffix.lower()
|
||||
if ext not in IMAGE_EXT:
|
||||
raise HTTPException(status_code=400, detail="그림 파일(png · jpg · gif · webp · bmp)만.")
|
||||
root = await _project_root(project_id)
|
||||
path = folder(root)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
await save_upload_stream(file, path / f".{route_id}{ext}.new")
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
for old in route_images(root, route_id):
|
||||
old.unlink(missing_ok=True)
|
||||
(path / f".{route_id}{ext}.new").replace(path / f"{route_id}{ext}")
|
||||
return {"name": f"{route_id}{ext}"}
|
||||
|
||||
|
||||
@router.get(PATH + "/image")
|
||||
async def milestone_image(project_id: UUID, route: str = Query(...)):
|
||||
route_id = _route_id(route)
|
||||
root = await _project_root(project_id)
|
||||
found = route_images(root, route_id)
|
||||
if not found:
|
||||
raise HTTPException(status_code=404, detail="경로 그림이 없습니다.")
|
||||
return FileResponse(found[0])
|
||||
|
||||
|
||||
@router.delete(PATH + "/image")
|
||||
async def milestone_image_delete(project_id: UUID, route: str = Query(...)) -> dict[str, Any]:
|
||||
route_id = _route_id(route)
|
||||
root = await _project_root(project_id)
|
||||
for old in route_images(root, route_id):
|
||||
old.unlink(missing_ok=True)
|
||||
return {"name": None}
|
||||
@@ -0,0 +1,45 @@
|
||||
/* B08 거리이정표(244) — 경로 컨테이너 · 단추 줄 · 그림. 색은 테마 변수만(밝음 · 어두움 같이). */
|
||||
|
||||
.b08-milestone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b08-milestone__route {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b08-milestone__route h4 {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b08-milestone__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
}
|
||||
|
||||
.b08-milestone__head > .ui-field {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.b08-milestone__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b08-milestone__image {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
align-self: flex-start;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Milestone.ts
|
||||
* B08 메인 탭 「거리이정표」(PLAN 244-1) — 하위 탭 줄 [거리이정표] + 경로 탭(업체마다 · 이름 = 업체 이름) +
|
||||
* [+ 추가] · [삭제].
|
||||
*
|
||||
* [거리이정표] = 표(항목 · 업체 이름 · 목적지 ▾ · 2차포장 · 1차포장 · 비포장 · 포장 · 비포장 · 합계 · 비고)
|
||||
* 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈
|
||||
* 입력 = 더블클릭(243) · 업체 이름을 고치면 그 경로 이름(하위 탭)도
|
||||
* 경로 탭 = `…_UI_Milestone_Route` · 데이터 · [저장] = `…_UI_Milestone_State`(좌측 패널 [저장] 한 줄)
|
||||
* ========================================================================== */
|
||||
|
||||
import "./B08_Quantity_UI_Milestone.css";
|
||||
import { createDataTable, type DataColumn } from "@ui/ui_template_data_table";
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
|
||||
import {
|
||||
liveMilestone,
|
||||
loadMilestone,
|
||||
onMilestoneChange,
|
||||
putMilestone,
|
||||
siteInfo,
|
||||
} from "./B08_Quantity_UI_Milestone_State";
|
||||
import {
|
||||
DEST_TEXT,
|
||||
editRoute,
|
||||
pickAtCell,
|
||||
routeView,
|
||||
trackCells,
|
||||
type MilestoneView,
|
||||
} from "./B08_Quantity_UI_Milestone_Route";
|
||||
|
||||
const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const;
|
||||
const COLUMNS: DataColumn[] = [
|
||||
{ key: "item", label: "항목", width: 120 },
|
||||
{ key: "company", label: "업체 이름", width: 120 },
|
||||
{ key: "dest", label: "목적지", width: 72 },
|
||||
{ key: "s2", label: "2차포장", group: ["도로 구분 (km)"], ...KM },
|
||||
{ key: "s1", label: "1차포장", group: ["도로 구분 (km)"], ...KM },
|
||||
{ key: "un", label: "비포장", group: ["도로 구분 (km)"], ...KM },
|
||||
{ key: "paved", label: "포장", group: ["계 (km)"], ...KM },
|
||||
{ key: "unpaved", label: "비포장", group: ["계 (km)"], ...KM },
|
||||
{ key: "total", label: "합계", group: ["계 (km)"], ...KM },
|
||||
{ key: "note", label: "비고", width: 160 },
|
||||
];
|
||||
|
||||
/** 새 경로(업체) — 캐시에 넣고 id 를 돌려줌 */
|
||||
export function addRoute(projectId: string, init: Partial<MilestoneRoute> = {}): string {
|
||||
const data = liveMilestone(projectId);
|
||||
const id = `r${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`;
|
||||
data.routes.push({
|
||||
id,
|
||||
name: `업체 ${data.routes.length + 1}`,
|
||||
address: "",
|
||||
via_base: true,
|
||||
dir: null,
|
||||
legs: [{ road: "2차포장", km: null }],
|
||||
rows: [{ item: "", dest: "start", note: "" }],
|
||||
...init,
|
||||
});
|
||||
putMilestone(projectId, data);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** [거리이정표] 하위 탭 — 표(+ 244-2 다이어그램 자리) */
|
||||
function tableView(projectId: string): MilestoneView {
|
||||
let keys: Array<{ route: string; index: number }> = [];
|
||||
const table = createDataTable({
|
||||
columns: COLUMNS,
|
||||
rows: [],
|
||||
caption: "거리이정표",
|
||||
empty: "",
|
||||
editOnDouble: true,
|
||||
editable: (_i, key) => key === "item" || key === "company" || key === "note",
|
||||
onEdit: (i, key, text) => {
|
||||
const at = keys[i];
|
||||
if (!at) return;
|
||||
editRoute(projectId, at.route, (_, r) => {
|
||||
if (key === "company") r.name = text;
|
||||
else if (r.rows[at.index]) r.rows[at.index][key as "item" | "note"] = text;
|
||||
});
|
||||
},
|
||||
onCellClick: (i, key) => {
|
||||
const at = keys[i];
|
||||
if (key !== "dest" || !at) return;
|
||||
pickAtCell(
|
||||
(["start", "mid"] as const).map((dest) => [
|
||||
DEST_TEXT[dest],
|
||||
() =>
|
||||
editRoute(projectId, at.route, (_, r) => {
|
||||
if (r.rows[at.index]) r.rows[at.index].dest = dest;
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
});
|
||||
trackCells(table.root);
|
||||
table.root.dataset.b08Milestone = "table";
|
||||
const none = el("p", {
|
||||
className: "ui-general-block__empty",
|
||||
text: "경로가 없습니다 — 하위 탭 줄 [+ 추가]로 업체를 넣으세요.",
|
||||
});
|
||||
const refresh = (): void => {
|
||||
const rows = computeMilestone(liveMilestone(projectId), siteInfo()?.mid_default_m ?? null);
|
||||
keys = rows.map(({ route, index }) => ({ route, index }));
|
||||
none.hidden = rows.length > 0;
|
||||
table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` })));
|
||||
};
|
||||
refresh();
|
||||
return { root: el("div", { className: "b08-milestone", children: [none, table.root] }), refresh };
|
||||
}
|
||||
|
||||
export function renderMilestone(host: HTMLElement, projectId: string): void {
|
||||
const tabs = el("div", {
|
||||
className: "b08-quantity__tabs b08-structure__tabs",
|
||||
attrs: { role: "tablist" },
|
||||
});
|
||||
const view = el("div");
|
||||
let current = "table";
|
||||
let body: MilestoneView | null = null;
|
||||
|
||||
const drawTabs = (): void => {
|
||||
const routes = liveMilestone(projectId).routes;
|
||||
const tab = (id: string, label: string): HTMLButtonElement => {
|
||||
const button = createButton({
|
||||
label,
|
||||
variant: id === current ? "filled" : "ghost",
|
||||
size: "sm",
|
||||
onClick: () => id !== current && open(id),
|
||||
});
|
||||
button.setAttribute("role", "tab");
|
||||
button.setAttribute("aria-selected", String(id === current));
|
||||
button.dataset.b08MilestoneTab = id;
|
||||
return button;
|
||||
};
|
||||
const add = createButton({
|
||||
label: "+ 추가",
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => open(addRoute(projectId)),
|
||||
});
|
||||
add.dataset.b08MilestoneAdd = "";
|
||||
const remove = createButton({
|
||||
label: "삭제",
|
||||
size: "sm",
|
||||
variant: "danger",
|
||||
onClick: () => void removeCurrent(),
|
||||
});
|
||||
remove.disabled = current === "table";
|
||||
remove.dataset.b08MilestoneDelete = "";
|
||||
tabs.replaceChildren(
|
||||
tab("table", "거리이정표"),
|
||||
...routes.map((r, i) => tab(r.id, r.name.trim() || `업체 ${i + 1}`)),
|
||||
add,
|
||||
remove,
|
||||
);
|
||||
};
|
||||
|
||||
const removeCurrent = async (): Promise<void> => {
|
||||
const data = liveMilestone(projectId);
|
||||
const route = data.routes.find((r) => r.id === current);
|
||||
if (!route) return;
|
||||
if (
|
||||
!(await showConfirmDialog(
|
||||
`「${route.name || "업체"}」 경로를 지울까요? — [저장] 때 반영`,
|
||||
"삭제",
|
||||
))
|
||||
)
|
||||
return;
|
||||
data.routes = data.routes.filter((r) => r.id !== route.id);
|
||||
putMilestone(projectId, data); // 알림이 없는 경로 탭을 보고 [거리이정표] 로 옮김
|
||||
};
|
||||
|
||||
function open(id: string): void {
|
||||
current = id;
|
||||
drawTabs();
|
||||
body = id === "table" ? tableView(projectId) : routeView(projectId, id);
|
||||
view.replaceChildren(body.root);
|
||||
}
|
||||
|
||||
host.replaceChildren(
|
||||
tabs,
|
||||
el("p", { className: "ui-general-block__empty", text: "거리이정표를 불러오는 중…" }),
|
||||
);
|
||||
loadMilestone(projectId).then(
|
||||
() => {
|
||||
host.replaceChildren(tabs, view);
|
||||
open("table");
|
||||
onMilestoneChange(host, () => {
|
||||
// 지운 경로 탭이면 [거리이정표] 로 · 아니면 탭 이름 · 몸만 다시 셈(입력 칸은 그대로)
|
||||
if (current !== "table" && !liveMilestone(projectId).routes.some((r) => r.id === current))
|
||||
return open("table");
|
||||
drawTabs();
|
||||
body?.refresh();
|
||||
});
|
||||
},
|
||||
(error: unknown) => showToast(`거리이정표를 불러오지 못했습니다 — ${String(error)}`, "error"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Milestone_Route.ts
|
||||
* 거리이정표 경로 하위 탭(PLAN 244-1) — 컨테이너 하나 = 업체 한 곳 · 그림 하나.
|
||||
*
|
||||
* 위 = 업체 이름(= 하위 탭 이름) · 주소 · 방위(기준점 · 없으면 공사지 기준 8방) · 기준점 거침
|
||||
* 구간 표 = 업체 → 기준점(또는 시점) · 기준점 → 시점(공통 · 모든 경로가 같이 씀) · 시점 → 중점(측점 km) ·
|
||||
* 구간마다 km(더블클릭 손 입력) · 도로 구분 ▾(2차포장 · 1차포장 · 비포장)
|
||||
* 표 줄 = 항목 · 목적지 ▾ · 비고 — [거리이정표] 표에 이 업체 줄로 나감
|
||||
* 그림 = 올리는 즉시 서버(교체 · 지우기) · 나머지 조작 = 캐시(`…_UI_Milestone_State`) · [저장] 때 서버
|
||||
* ========================================================================== */
|
||||
|
||||
import { createCheckbox } from "@ui/ui_template_checkbox";
|
||||
import {
|
||||
createMapContextMenu,
|
||||
type MapContextMenu,
|
||||
type MapContextMenuItem,
|
||||
} from "@ui/ui_template_context_menu";
|
||||
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import {
|
||||
ROADS,
|
||||
midKm,
|
||||
type Dest,
|
||||
type Dir,
|
||||
type MilestoneData,
|
||||
type MilestoneRoute,
|
||||
} from "./B08_Quantity_Milestone_Calc";
|
||||
import {
|
||||
deleteImage,
|
||||
dirOptions,
|
||||
imageBlobUrl,
|
||||
liveMilestone,
|
||||
putMilestone,
|
||||
routeImages,
|
||||
siteInfo,
|
||||
uploadImage,
|
||||
} from "./B08_Quantity_UI_Milestone_State";
|
||||
|
||||
let menu: MapContextMenu | null = null;
|
||||
let lastCell: Element | null = null;
|
||||
|
||||
/** 표 칸 누름 자리 기억 — 「▾」 메뉴를 그 칸 아래에 엶 */
|
||||
export function trackCells(root: HTMLElement): void {
|
||||
root.addEventListener("click", (e) => (lastCell = (e.target as Element).closest("td")), true);
|
||||
}
|
||||
|
||||
export function pickAtCell(items: MapContextMenuItem[]): void {
|
||||
const box = lastCell?.getBoundingClientRect();
|
||||
if (!box) return;
|
||||
menu ??= createMapContextMenu("b08-milestone");
|
||||
if (!menu.element.isConnected) {
|
||||
menu.element.style.position = "fixed";
|
||||
document.body.append(menu.element);
|
||||
}
|
||||
menu.open(box.left, box.bottom, items);
|
||||
}
|
||||
|
||||
/** 고른 줄 강조(공용 표 is-focus · 다시 그리지 않음 — 더블클릭 그대로) */
|
||||
function markRow(root: HTMLElement, index: number | null): void {
|
||||
root.querySelectorAll("tr.is-focus").forEach((tr) => tr.classList.remove("is-focus"));
|
||||
if (index !== null) root.querySelector(`tr[data-i="${index}"]`)?.classList.add("is-focus");
|
||||
}
|
||||
|
||||
export const DEST_TEXT: Record<Dest, string> = { start: "시점", mid: "중점" };
|
||||
|
||||
/** 그 경로를 고쳐 캐시에 */
|
||||
export function editRoute(
|
||||
projectId: string,
|
||||
routeId: string,
|
||||
fn: (data: MilestoneData, route: MilestoneRoute) => void,
|
||||
): void {
|
||||
const data = liveMilestone(projectId);
|
||||
const route = data.routes.find((r) => r.id === routeId);
|
||||
if (!route) return;
|
||||
fn(data, route);
|
||||
putMilestone(projectId, data);
|
||||
}
|
||||
|
||||
const kmOf = (text: string): number | null | undefined => {
|
||||
if (!text.trim()) return null;
|
||||
const value = Number(text.replace(/,/g, ""));
|
||||
return Number.isFinite(value) && value >= 0 ? value : undefined; // undefined = 그대로
|
||||
};
|
||||
|
||||
type LegRef = { group: "own" | "base" | "mid"; i: number };
|
||||
|
||||
const LEG_COLUMNS: DataColumn[] = [
|
||||
{ key: "span", label: "구간", width: 200 },
|
||||
{ key: "road", label: "도로 구분", width: 96 },
|
||||
{ key: "km", label: "km", kind: "number", decimals: 3, trim: true, width: 80 },
|
||||
];
|
||||
const ROW_COLUMNS: DataColumn[] = [
|
||||
{ key: "item", label: "항목", width: 140 },
|
||||
{ key: "dest", label: "목적지", width: 80 },
|
||||
{ key: "note", label: "비고", width: 180 },
|
||||
];
|
||||
|
||||
export interface MilestoneView {
|
||||
root: HTMLElement;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function routeView(projectId: string, routeId: string): MilestoneView {
|
||||
const edit = (fn: (data: MilestoneData, route: MilestoneRoute) => void): void =>
|
||||
editRoute(projectId, routeId, fn);
|
||||
|
||||
// 위 — 업체 이름 · 주소 · 방위 · 기준점 거침
|
||||
const name = createInputField({ label: "업체 이름", compact: true });
|
||||
const address = createInputField({ label: "주소", compact: true });
|
||||
const dir = createSelectField({ label: "방위", options: dirOptions, compact: true });
|
||||
const via = createCheckbox("기준점 거침");
|
||||
name.input.dataset.b08Milestone = "route_name";
|
||||
name.input.addEventListener("input", () => edit((_, r) => (r.name = name.input.value)));
|
||||
address.input.addEventListener("input", () => edit((_, r) => (r.address = address.input.value)));
|
||||
dir.select.addEventListener("change", () =>
|
||||
edit((_, r) => (r.dir = (dir.select.value || null) as Dir | null)),
|
||||
);
|
||||
via.input.addEventListener("change", () => edit((_, r) => (r.via_base = via.input.checked)));
|
||||
const head = el("div", {
|
||||
className: "b08-milestone__head",
|
||||
children: [name.root, address.root, dir.root, via.root],
|
||||
});
|
||||
|
||||
// 구간 표
|
||||
let legRefs: LegRef[] = [];
|
||||
let pickedLeg: number | null = null;
|
||||
const legRows = (d: MilestoneData, r: MilestoneRoute): DataRow[] => {
|
||||
const useBase = d.base.enabled && r.via_base;
|
||||
const baseName = d.base.name || "기준점";
|
||||
const who = r.name || "업체";
|
||||
const rows: Array<[LegRef, string, string, number | null]> = [
|
||||
...r.legs.map((leg, i): [LegRef, string, string, number | null] => [
|
||||
{ group: "own", i },
|
||||
`${who} → ${useBase ? baseName : "시점"}`,
|
||||
leg.road,
|
||||
leg.km,
|
||||
]),
|
||||
...(useBase ? d.base.legs : []).map((leg, i): [LegRef, string, string, number | null] => [
|
||||
{ group: "base", i },
|
||||
`${baseName} → 시점 (공통)`,
|
||||
leg.road,
|
||||
leg.km,
|
||||
]),
|
||||
[
|
||||
{ group: "mid", i: 0 },
|
||||
"시점 → 중점 (측점)",
|
||||
d.site.mid_road,
|
||||
midKm(d, siteInfo()?.mid_default_m ?? null),
|
||||
],
|
||||
];
|
||||
legRefs = rows.map(([ref]) => ref);
|
||||
return rows.map(([, span, road, km]) => ({ span, road: `${road} ▾`, km }));
|
||||
};
|
||||
const setLeg = (ref: LegRef, fn: (leg: { road: string; km: number | null }) => void): void =>
|
||||
edit((d, r) => {
|
||||
if (ref.group === "mid") {
|
||||
const leg = { road: d.site.mid_road as string, km: null };
|
||||
fn(leg);
|
||||
d.site.mid_road = leg.road as MilestoneData["site"]["mid_road"];
|
||||
} else fn((ref.group === "own" ? r.legs : d.base.legs)[ref.i]);
|
||||
});
|
||||
const legs = createDataTable({
|
||||
columns: LEG_COLUMNS,
|
||||
rows: [],
|
||||
caption: "구간",
|
||||
empty: "",
|
||||
editOnDouble: true,
|
||||
editable: (i, key) => key === "km" && legRefs[i]?.group !== "mid",
|
||||
onEdit: (i, _key, text) => {
|
||||
const km = kmOf(text);
|
||||
if (km !== undefined) setLeg(legRefs[i], (leg) => (leg.km = km));
|
||||
else refresh();
|
||||
},
|
||||
onCellClick: (i, key) => {
|
||||
if (key !== "road") return;
|
||||
pickAtCell(ROADS.map((road) => [road, () => setLeg(legRefs[i], (leg) => (leg.road = road))]));
|
||||
},
|
||||
onRowClick: (i) => markRow(legs.root, (pickedLeg = i)),
|
||||
});
|
||||
trackCells(legs.root);
|
||||
legs.root.dataset.b08Milestone = "legs";
|
||||
const addOwn = createButton({
|
||||
label: "+ 업체 구간",
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => edit((_, r) => r.legs.push({ road: "2차포장", km: null })),
|
||||
});
|
||||
const addBase = createButton({
|
||||
label: "+ 공통 구간",
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => edit((d) => d.base.legs.push({ road: "1차포장", km: null })),
|
||||
});
|
||||
const dropLeg = createButton({
|
||||
label: "고른 구간 지우기",
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
const ref = pickedLeg === null ? null : legRefs[pickedLeg];
|
||||
if (!ref || ref.group === "mid") return showToast("지울 구간 줄을 먼저 고르세요.", "info");
|
||||
pickedLeg = null;
|
||||
edit((d, r) => (ref.group === "own" ? r.legs : d.base.legs).splice(ref.i, 1));
|
||||
},
|
||||
});
|
||||
|
||||
// 표 줄
|
||||
let pickedRow: number | null = null;
|
||||
const rows = createDataTable({
|
||||
columns: ROW_COLUMNS,
|
||||
rows: [],
|
||||
caption: "표 줄",
|
||||
empty: "",
|
||||
editOnDouble: true,
|
||||
editable: (_i, key) => key === "item" || key === "note",
|
||||
onEdit: (i, key, text) =>
|
||||
edit((_, r) => {
|
||||
if (r.rows[i]) r.rows[i][key as "item" | "note"] = text;
|
||||
}),
|
||||
onCellClick: (i, key) => {
|
||||
if (key !== "dest") return;
|
||||
pickAtCell(
|
||||
(["start", "mid"] as Dest[]).map((dest) => [
|
||||
DEST_TEXT[dest],
|
||||
() => edit((_, r) => r.rows[i] && (r.rows[i].dest = dest)),
|
||||
]),
|
||||
);
|
||||
},
|
||||
onRowClick: (i) => markRow(rows.root, (pickedRow = i)),
|
||||
});
|
||||
trackCells(rows.root);
|
||||
rows.root.dataset.b08Milestone = "rows";
|
||||
const addRow = createButton({
|
||||
label: "+ 줄",
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => edit((_, r) => r.rows.push({ item: "", dest: "start", note: "" })),
|
||||
});
|
||||
const dropRow = createButton({
|
||||
label: "고른 줄 지우기",
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
if (pickedRow === null) return showToast("지울 줄을 먼저 고르세요.", "info");
|
||||
const at = pickedRow;
|
||||
pickedRow = null;
|
||||
edit((_, r) => r.rows.splice(at, 1));
|
||||
},
|
||||
});
|
||||
|
||||
// 그림 — 경로 하나 = 그림 하나
|
||||
const img = el("img", { className: "b08-milestone__image", attrs: { alt: "경로 그림" } });
|
||||
const noImage = el("p", { className: "ui-general-block__empty", text: "올린 그림 없음" });
|
||||
let shown: string | null | undefined;
|
||||
const loadImage = (force = false): void => {
|
||||
const name = routeImages()[routeId] ?? null;
|
||||
if (!force && name === shown) return;
|
||||
shown = name;
|
||||
img.hidden = !name;
|
||||
noImage.hidden = !!name;
|
||||
if (img.src) URL.revokeObjectURL(img.src);
|
||||
img.removeAttribute("src");
|
||||
if (name)
|
||||
imageBlobUrl(projectId, routeId).then(
|
||||
(url) => (img.src = url),
|
||||
(error: unknown) => showToast(`그림을 못 읽었습니다 — ${String(error)}`, "error"),
|
||||
);
|
||||
};
|
||||
const file = el("input", { attrs: { type: "file", accept: "image/*" } });
|
||||
file.hidden = true;
|
||||
file.addEventListener("change", () => {
|
||||
const picked = file.files?.[0];
|
||||
file.value = "";
|
||||
if (picked)
|
||||
uploadImage(projectId, routeId, picked).then(
|
||||
() => loadImage(true),
|
||||
(error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"),
|
||||
);
|
||||
});
|
||||
const upload = createButton({
|
||||
label: "그림 올리기",
|
||||
size: "sm",
|
||||
variant: "filled",
|
||||
onClick: () => file.click(),
|
||||
});
|
||||
const removeImage = createButton({
|
||||
label: "그림 지우기",
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void deleteImage(projectId, routeId).catch((error: unknown) =>
|
||||
showToast(`그림을 지우지 못했습니다 — ${String(error)}`, "error"),
|
||||
),
|
||||
});
|
||||
|
||||
const bar = (...items: HTMLElement[]): HTMLElement =>
|
||||
el("div", { className: "b08-milestone__bar", children: items });
|
||||
const root = el("section", {
|
||||
className: "b08-milestone__route ui-card",
|
||||
attrs: { "data-b08-milestone-route": routeId },
|
||||
children: [
|
||||
head,
|
||||
el("h4", { text: "구간 (km 더블클릭 · 도로 구분 ▾)" }),
|
||||
legs.root,
|
||||
bar(addOwn, addBase, dropLeg),
|
||||
el("h4", { text: "표 줄" }),
|
||||
rows.root,
|
||||
bar(addRow, dropRow),
|
||||
el("h4", { text: "경로 그림" }),
|
||||
bar(upload, removeImage, file),
|
||||
noImage,
|
||||
img,
|
||||
],
|
||||
});
|
||||
|
||||
const refresh = (): void => {
|
||||
const d = liveMilestone(projectId);
|
||||
const r = d.routes.find((x) => x.id === routeId);
|
||||
if (!r) return;
|
||||
if (document.activeElement !== name.input) name.input.value = r.name;
|
||||
if (document.activeElement !== address.input) address.input.value = r.address;
|
||||
dir.select.value = r.dir ?? "";
|
||||
via.input.checked = r.via_base;
|
||||
via.root.hidden = !d.base.enabled;
|
||||
addBase.hidden = !(d.base.enabled && r.via_base);
|
||||
legs.setRows(legRows(d, r));
|
||||
rows.setRows(r.rows.map((row) => ({ ...row, dest: `${DEST_TEXT[row.dest]} ▾` })));
|
||||
markRow(legs.root, pickedLeg);
|
||||
markRow(rows.root, pickedRow);
|
||||
loadImage();
|
||||
};
|
||||
refresh();
|
||||
return { root, refresh };
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Milestone_State.ts
|
||||
* 거리이정표(PLAN 244-1) 데이터 층 — 저장본 · 캐시 · [저장] · 경로 그림 길 + 좌측 패널 카드(공사지 · 기준점).
|
||||
*
|
||||
* 저장본 = GET …/quantity/milestone(서버 정본 · 표는 서버가 셈) · 조작 = 캐시 `b08-milestone` 즉시 +
|
||||
* 표는 TS 짝(`…_Milestone_Calc`)이 바로 셈 · [저장](좌측 패널 한 줄) = PUT → 캐시 비움 · 자동 저장 없음.
|
||||
* 그림 = 올리는 즉시 서버(경로 하나 = 그림 하나 · B09 견적서 꼴).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createCheckbox } from "@ui/ui_template_checkbox";
|
||||
import { createInputField, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { request } from "./B08_Quantity_UI_Hydro";
|
||||
import { DIRS, ROADS, type MilestoneData, type Road } from "./B08_Quantity_Milestone_Calc";
|
||||
|
||||
export interface SiteInfo {
|
||||
length_m: number | null;
|
||||
mid_default_m: number | null;
|
||||
start_lonlat: [number, number] | null;
|
||||
mid_lonlat: [number, number] | null;
|
||||
}
|
||||
|
||||
interface Payload {
|
||||
data: MilestoneData;
|
||||
site: SiteInfo;
|
||||
images: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 8방 이름 */
|
||||
export const DIR_TEXT: Record<string, string> = {
|
||||
N: "북",
|
||||
NE: "북동",
|
||||
E: "동",
|
||||
SE: "남동",
|
||||
S: "남",
|
||||
SW: "남서",
|
||||
W: "서",
|
||||
NW: "북서",
|
||||
};
|
||||
|
||||
export const EMPTY: MilestoneData = {
|
||||
site: { mid_station_m: null, mid_road: "비포장" },
|
||||
base: { enabled: false, name: "", address: "", dir: null, legs: [] },
|
||||
routes: [],
|
||||
};
|
||||
|
||||
const changes = new EventTarget();
|
||||
let saved: Payload | null = null;
|
||||
let savedFor: string | null = null;
|
||||
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
|
||||
/** 저장본 읽기(프로젝트마다 한 번 · force = 다시) */
|
||||
export async function loadMilestone(projectId: string, force = false): Promise<Payload> {
|
||||
if (!saved || savedFor !== projectId || force) {
|
||||
saved = await request<Payload>(`/projects/${projectId}/quantity/milestone`);
|
||||
savedFor = projectId;
|
||||
changes.dispatchEvent(new Event("change"));
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
export const siteInfo = (): SiteInfo | null => saved?.site ?? null;
|
||||
export const routeImages = (): Record<string, string> => saved?.images ?? {};
|
||||
|
||||
export const hasMilestoneDraft = (projectId: string): boolean =>
|
||||
readState<MilestoneData>("b08-milestone", projectId) !== null;
|
||||
|
||||
/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본) */
|
||||
export function liveMilestone(projectId: string): MilestoneData {
|
||||
return clone(readState<MilestoneData>("b08-milestone", projectId) ?? saved?.data ?? EMPTY);
|
||||
}
|
||||
|
||||
/** 고친 한 벌을 캐시에 · 화면에 알림 */
|
||||
export function putMilestone(projectId: string, data: MilestoneData): void {
|
||||
writeState("b08-milestone", data, projectId);
|
||||
changes.dispatchEvent(new Event("change"));
|
||||
}
|
||||
|
||||
/** host 가 붙어 있는 동안 바뀔 때마다 fn — 떨어진 뒤 첫 알림에서 스스로 뗌 */
|
||||
export function onMilestoneChange(host: HTMLElement, fn: () => void): void {
|
||||
const listener = (): void => {
|
||||
if (host.isConnected) fn();
|
||||
else changes.removeEventListener("change", listener);
|
||||
};
|
||||
changes.addEventListener("change", listener);
|
||||
}
|
||||
|
||||
/** [저장] — 캐시가 있을 때만 PUT · 서버가 표를 다시 셈 */
|
||||
export async function saveMilestone(projectId: string): Promise<void> {
|
||||
const draft = readState<MilestoneData>("b08-milestone", projectId);
|
||||
if (!draft) return;
|
||||
saved = await request<Payload>(`/projects/${projectId}/quantity/milestone`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(draft),
|
||||
});
|
||||
savedFor = projectId;
|
||||
clearState("b08-milestone", projectId);
|
||||
changes.dispatchEvent(new Event("change"));
|
||||
}
|
||||
|
||||
const imageUrl = (projectId: string, routeId: string): string =>
|
||||
`${API_BASE_URL}/projects/${projectId}/quantity/milestone/image?route=${encodeURIComponent(routeId)}`;
|
||||
|
||||
async function call(input: string, init: RequestInit = {}): Promise<Response> {
|
||||
const response = await fetch(input, { credentials: "include", ...init });
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { detail?: unknown } | null;
|
||||
throw new Error(typeof body?.detail === "string" ? body.detail : `HTTP ${response.status}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/** 경로 그림 — 올리기(교체) · 지우기는 즉시 서버 · 목록 고쳐 알림 */
|
||||
export async function uploadImage(projectId: string, routeId: string, file: File): Promise<void> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const body = (await (
|
||||
await call(imageUrl(projectId, routeId), { method: "POST", body: form })
|
||||
).json()) as { name: string };
|
||||
if (saved) saved.images = { ...saved.images, [routeId]: body.name };
|
||||
changes.dispatchEvent(new Event("change"));
|
||||
}
|
||||
|
||||
export async function deleteImage(projectId: string, routeId: string): Promise<void> {
|
||||
await call(imageUrl(projectId, routeId), { method: "DELETE" });
|
||||
if (saved) {
|
||||
const rest = { ...saved.images };
|
||||
delete rest[routeId];
|
||||
saved.images = rest;
|
||||
}
|
||||
changes.dispatchEvent(new Event("change"));
|
||||
}
|
||||
|
||||
/** 그림 → 화면 주소(blob · 쿠키 포함) */
|
||||
export async function imageBlobUrl(projectId: string, routeId: string): Promise<string> {
|
||||
return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob());
|
||||
}
|
||||
|
||||
export const roadOptions = ROADS.map((r) => ({ value: r, text: r }));
|
||||
export const dirOptions = [
|
||||
{ value: "", text: "방위 —" },
|
||||
...DIRS.map((d) => ({ value: d, text: DIR_TEXT[d] })),
|
||||
];
|
||||
|
||||
const km = (m: number | null | undefined): string =>
|
||||
m == null ? "-" : (m / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||
|
||||
/** 좌측 패널 「거리이정표」 카드 몸 — 공사지(시점 · 중점) · 기준점(있음 · 이름 · 주소 · 방위) */
|
||||
export function milestoneCardBody(projectId: string | null): HTMLElement[] {
|
||||
const mid = createInputField({ label: "중점 측점 (m)", type: "number", compact: true, min: 0 });
|
||||
const midRoad = createSelectField({
|
||||
label: "시점 → 중점 도로",
|
||||
options: roadOptions,
|
||||
compact: true,
|
||||
});
|
||||
const siteHint = el("p", { className: "b08-options__hint" });
|
||||
const has = createCheckbox("기준점 있음");
|
||||
const name = createInputField({ label: "기준점 이름", compact: true });
|
||||
const address = createInputField({ label: "기준점 주소", compact: true });
|
||||
const dir = createSelectField({
|
||||
label: "공사지에서 본 방위",
|
||||
options: dirOptions,
|
||||
compact: true,
|
||||
});
|
||||
mid.input.dataset.b08Milestone = "mid_station_m";
|
||||
has.input.dataset.b08Milestone = "base_enabled";
|
||||
const baseFields = el("div", { children: [name.root, address.root, dir.root] });
|
||||
|
||||
const show = (): void => {
|
||||
if (!projectId) return;
|
||||
const data = liveMilestone(projectId);
|
||||
const site = siteInfo();
|
||||
mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m) : "";
|
||||
if (document.activeElement !== mid.input)
|
||||
mid.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m);
|
||||
midRoad.select.value = data.site.mid_road;
|
||||
siteHint.textContent =
|
||||
`시점 = 노선 첫 점(측점 0) · 노선 ${km(site?.length_m)} km · ` +
|
||||
`중점 기본 = 노선 ÷ 2(${km(site?.mid_default_m)} km) · 빈칸 = 기본`;
|
||||
has.input.checked = data.base.enabled;
|
||||
baseFields.hidden = !data.base.enabled;
|
||||
if (document.activeElement !== name.input) name.input.value = data.base.name;
|
||||
if (document.activeElement !== address.input) address.input.value = data.base.address;
|
||||
dir.select.value = data.base.dir ?? "";
|
||||
};
|
||||
const edit = (fn: (data: MilestoneData) => void): void => {
|
||||
if (!projectId) return;
|
||||
const data = liveMilestone(projectId);
|
||||
fn(data);
|
||||
putMilestone(projectId, data);
|
||||
};
|
||||
mid.input.addEventListener("input", () =>
|
||||
edit((d) => {
|
||||
const value = Number(mid.input.value);
|
||||
d.site.mid_station_m = mid.input.value.trim() && value >= 0 ? value : null;
|
||||
}),
|
||||
);
|
||||
midRoad.select.addEventListener("change", () =>
|
||||
edit((d) => (d.site.mid_road = midRoad.select.value as Road)),
|
||||
);
|
||||
has.input.addEventListener("change", () =>
|
||||
edit((d) => {
|
||||
d.base.enabled = has.input.checked;
|
||||
if (d.base.enabled && !d.base.legs.length) d.base.legs = [{ road: "1차포장", km: null }];
|
||||
}),
|
||||
);
|
||||
name.input.addEventListener("input", () => edit((d) => (d.base.name = name.input.value)));
|
||||
address.input.addEventListener("input", () =>
|
||||
edit((d) => (d.base.address = address.input.value)),
|
||||
);
|
||||
dir.select.addEventListener("change", () =>
|
||||
edit((d) => (d.base.dir = (dir.select.value || null) as MilestoneData["base"]["dir"])),
|
||||
);
|
||||
|
||||
const host = el("div", {
|
||||
children: [mid.root, midRoad.root, siteHint, has.root, baseFields],
|
||||
});
|
||||
show();
|
||||
onMilestoneChange(host, show); // 첫 알림(읽기 끝)은 패널이 붙은 뒤
|
||||
if (projectId) void loadMilestone(projectId).catch(() => undefined);
|
||||
return [host];
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Options.ts
|
||||
* 좌측 패널(PLAN 122 · 160-1) — 메인 탭과 같은 컨테이너 넷(구조물 집계표 · 자재 · 토공 · 수리집수면적표 · 접이)
|
||||
* 좌측 패널(PLAN 122 · 160-1 · 244-1) — 메인 탭과 같은 컨테이너 다섯(구조물 집계표 · 자재 · 토공 · 수리집수면적표 ·
|
||||
* 거리이정표 · 접이)
|
||||
* + [저장] 줄.
|
||||
*
|
||||
* 구조물 집계표 › 배관 1본 길이(m) — 정본 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m`
|
||||
* 자재 › 할증 모래 · 자갈 · 시멘트 %(PLAN 124) — 총괄자재수량집계표 그 세 줄 · 0~100
|
||||
* 토공 › 연암 % · 경암 %(암 전체 기준 · 한쪽 고치면 다른 쪽 = 100 − 값 · 0~100)
|
||||
* 수리집수면적표 — 빈 자리(다음 옵션)
|
||||
* 거리이정표 › 공사지(중점 측점 · 시점 → 중점 도로) · 기준점 — `…_UI_Milestone_State`(캐시 `b08-milestone` ·
|
||||
* [저장] 한 번에 같이 PUT)
|
||||
* 조작 = 캐시 `b08-options` 즉시 + 열린 표가 같은 식으로 바로 다시 셈(`…_Options_Calc`) ·
|
||||
* [저장] = PUT …/quantity/options → 캐시 비움 · 열린 탭을 서버 정본으로 다시 그림 · 자동 저장 없음.
|
||||
* ========================================================================== */
|
||||
@@ -24,6 +27,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { request } from "./B08_Quantity_UI_Hydro";
|
||||
import { clampPct } from "./B08_Quantity_Options_Calc";
|
||||
import { PANEL_SURCHARGE, type Cells } from "./B08_Quantity_Material_Calc";
|
||||
import { milestoneCardBody, saveMilestone } from "./B08_Quantity_UI_Milestone_State";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -210,6 +214,7 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void
|
||||
surcharge: body.material_surcharge_pct,
|
||||
};
|
||||
clearState("b08-options", projectId);
|
||||
await saveMilestone(projectId);
|
||||
show();
|
||||
showToast("수량 산출 옵션을 저장했습니다.", "success");
|
||||
onSaved();
|
||||
@@ -237,6 +242,7 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void
|
||||
card("material", "B08_Quantity_Tab_Materials", ...surchargeBlock),
|
||||
card("earthwork", "B08_Quantity_Tab_Earthwork", rockRow, hint("암 전체 기준 · 두 칸 합 100")),
|
||||
card("hydrology", "B08_Quantity_Tab_Hydrology", hint("다음 옵션 자리")),
|
||||
card("milestone", "B08_Quantity_Tab_Milestone", ...milestoneCardBody(projectId)),
|
||||
actions,
|
||||
);
|
||||
attachCollapsible(panel);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* B08_Quantity_UI_Page.ts
|
||||
* 로그인 후 08: 5차 워크플로우 (수량 산출) — 위 탭 화면 (PLAN 61)
|
||||
*
|
||||
* 탭 = 구조물 집계표(61-1) · 자재 · 토공 · 수리집수면적표 (PLAN 95-1 · 160-1). 위 탭 단추 = 공용
|
||||
* 탭 = 구조물 집계표(61-1) · 자재 · 토공 · 수리집수면적표 (PLAN 95-1 · 160-1) · 거리이정표(244-1). 위 탭 단추 = 공용
|
||||
* 기본 크기(md) · 보조 탭 단추 = sm(PLAN 69-1). 구조물도는 집계표 행을 눌러 여는 모달(95-3).
|
||||
* 자재 아래 보조 탭 = 총괄자재수량집계표 · 콘크리트소요자재집계표 · 자재수량집계표(121-1 `…_UI_Material`).
|
||||
* 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀.
|
||||
@@ -22,6 +22,7 @@ import { renderMaterialSheet } from "./B08_Quantity_UI_Material";
|
||||
import { renderHydro } from "./B08_Quantity_UI_Hydro";
|
||||
import { renderEarthwork } from "./B08_Quantity_UI_Earthwork";
|
||||
import { createOptionsPanel } from "./B08_Quantity_UI_Options";
|
||||
import { renderMilestone } from "./B08_Quantity_UI_Milestone";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -32,6 +33,7 @@ const TABS = [
|
||||
{ id: "material", label: "B08_Quantity_Tab_Materials" },
|
||||
{ id: "earthwork", label: "B08_Quantity_Tab_Earthwork" },
|
||||
{ id: "hydrology", label: "B08_Quantity_Tab_Hydrology" },
|
||||
{ id: "milestone", label: "B08_Quantity_Tab_Milestone" },
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof TABS)[number]["id"];
|
||||
@@ -101,6 +103,7 @@ function renderTab(host: HTMLElement, tab: TabId, projectId: string | null): voi
|
||||
}
|
||||
if (tab === "hydrology" && projectId) return void renderHydro(view, projectId);
|
||||
if (tab === "earthwork" && projectId) return void renderEarthwork(view, projectId);
|
||||
if (tab === "milestone" && projectId) return renderMilestone(view, projectId);
|
||||
view.append(pendingNote());
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ from B08_Quantity.B08_Quantity_Router_Hydro import router as b08_hydro_router
|
||||
from B08_Quantity.B08_Quantity_Router_Options import router as b08_options_router
|
||||
from B08_Quantity.B08_Quantity_Router_Summary import router as b08_summary_router
|
||||
from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router
|
||||
from B08_Quantity.B08_Quantity_Router_Milestone import router as b08_milestone_router
|
||||
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
|
||||
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
|
||||
from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router
|
||||
@@ -656,6 +657,7 @@ app.include_router(b08_earthwork_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_summary_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_material_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_options_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_milestone_router, dependencies=protected_with_company)
|
||||
app.include_router(b09_estimation_router, dependencies=protected_with_company)
|
||||
# B08 수량·B09 원가계산 라우터 13 개는 끊음 — 코드는 `old_code/` 에 있고 화면은 빈 틀만 뜬다(PLAN 7-3).
|
||||
# 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다.
|
||||
|
||||
@@ -140,7 +140,7 @@ def test_좌측_패널_컨테이너_셋과_저장_줄():
|
||||
panel = (ROOT / "B08_Quantity/B08_Quantity_UI_Options.ts").read_text(encoding="utf-8")
|
||||
assert "leftPanel: createOptionsPanel(projectId, () => select(current))" in page
|
||||
# 160-1 — 메인 탭 차례 = 구조물 집계표 · 자재 · 토공 · 수리 · 카드도 같은 차례(1본 → 집계표 · 할증 → 자재)
|
||||
tabs = ("summary", "material", "earthwork", "hydrology")
|
||||
tabs = ("summary", "material", "earthwork", "hydrology", "milestone") # 244-1 거리이정표
|
||||
ids = re.findall(r'\{ id: "(\w+)", label: "B08_Quantity_Tab_', page)
|
||||
assert ids == [
|
||||
*tabs,
|
||||
|
||||
@@ -127,7 +127,8 @@ def test_B08_고칠_칸_있는_표_모두_editOnDouble():
|
||||
if "editable:" in block:
|
||||
seen += 1
|
||||
assert "editOnDouble: true" in block, p.name
|
||||
assert seen == 1 # 자재 집계표 셋이 한 표 만드는 길(Material)을 같이 씀
|
||||
# 자재 집계표 셋이 한 표 만드는 길(Material)을 같이 씀 + 거리이정표 셋(표 · 구간 · 표 줄 · 244-1)
|
||||
assert seen == 4
|
||||
|
||||
|
||||
def test_고르기_메뉴_이동_그대로():
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""244-1 B08 거리이정표 — 표 합 거울(파이썬 · TS) · 저장 왕복(임시 storage) · 그림 ·
|
||||
하위 탭 추가 · 삭제 · 중점 기본.
|
||||
|
||||
견본 = 실무 「거리이정표」 시트(거창군청 → 병곡리 968-31 → 시점 · 김천역 → 임도중점)
|
||||
장비 = 2차 23.8 + 1차 0.6 + 비포장 0.8 → 포장 24.4 · 비포장 0.8 · 합계 25.2.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402
|
||||
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
|
||||
|
||||
def _leg(road, km):
|
||||
return {"road": road, "km": km}
|
||||
|
||||
|
||||
SAMPLE = {
|
||||
"site": {"mid_station_m": None, "mid_road": "비포장"},
|
||||
"base": {"enabled": True, "name": "병곡리 968-31", "address": "", "dir": "E",
|
||||
"legs": [_leg("1차포장", 0.6), _leg("비포장", 0.8)]},
|
||||
"routes": [
|
||||
{"id": "r1", "name": "거창군청", "address": "", "via_base": True, "dir": "W",
|
||||
"legs": [_leg("2차포장", 23.8)],
|
||||
"rows": [{"item": "장비(20톤)", "dest": "start", "note": ""},
|
||||
{"item": "장비(8톤)", "dest": "start", "note": "같은 길"}]},
|
||||
{"id": "r2", "name": "김천역", "address": "", "via_base": False, "dir": "N",
|
||||
"legs": [_leg("2차포장", 61.2), _leg("1차포장", 2.05), _leg("비포장", None)],
|
||||
"rows": [{"item": "철근", "dest": "mid", "note": ""}]},
|
||||
],
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def _run_ts(data, mid_default):
|
||||
calc = ROOT / "B08_Quantity" / "B08_Quantity_Milestone_Calc.ts"
|
||||
body = (
|
||||
f"\nconst d = {json.dumps(data, ensure_ascii=False)};"
|
||||
f"\nconsole.log(JSON.stringify(computeMilestone(d, {json.dumps(mid_default)})));"
|
||||
)
|
||||
script = IMPORTS.sub("", calc.read_text(encoding="utf-8")) + body
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_견본_표_합():
|
||||
rows = compute_milestone(SAMPLE, 1500.0)
|
||||
a, b, c = rows
|
||||
assert (a["s2"], a["s1"], a["un"]) == (23.8, 0.6, 0.8)
|
||||
assert (a["paved"], a["unpaved"], a["total"]) == (24.4, 0.8, 25.2)
|
||||
assert b["item"] == "장비(8톤)" and b["total"] == 25.2 and b["company"] == "거창군청"
|
||||
# 기준점 안 거침 · 목적지 중점 = 시점 → 중점(기본 1500 ÷ 1000) 비포장 더함 · 빈 km = 0
|
||||
assert (c["s2"], c["s1"], c["un"], c["total"]) == (61.2, 2.05, 1.5, 64.75)
|
||||
# 사용자 중점 측점 · 기준점 없음 = 공통 구간 안 셈
|
||||
no_base = {**SAMPLE, "base": {**SAMPLE["base"], "enabled": False},
|
||||
"site": {"mid_station_m": 420, "mid_road": "1차포장"}} # fmt: skip
|
||||
a2, _, c2 = compute_milestone(no_base, 1500.0)
|
||||
assert a2["total"] == 23.8 and (c2["s1"], c2["un"]) == (2.47, 0)
|
||||
|
||||
|
||||
def test_거울_TS():
|
||||
for mid in (1500.0, None):
|
||||
assert _run_ts(SAMPLE, mid) == compute_milestone(SAMPLE, mid)
|
||||
odd = {**SAMPLE, "site": {"mid_station_m": 1234.5678, "mid_road": "비포장"}}
|
||||
assert _run_ts(odd, 10.0) == compute_milestone(odd, 10.0)
|
||||
|
||||
|
||||
def _route_csv(root: Path, points):
|
||||
path = root / "B05_Profile" / "route" / "planned_route.csv"
|
||||
path.parent.mkdir(parents=True)
|
||||
lines = ["sequence,x,y"] + [f"{i},{x},{y}" for i, (x, y) in enumerate(points)]
|
||||
path.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def test_중점_기본_노선_절반(tmp_path):
|
||||
assert ms.site_info(tmp_path, None)["mid_default_m"] is None # 노선 전 = 빈칸
|
||||
_route_csv(tmp_path, [(208000, 367000), (209000, 367000), (209000, 367500)])
|
||||
site = ms.site_info(tmp_path, None)
|
||||
assert site["length_m"] == 1500 and site["mid_default_m"] == 750
|
||||
lon, lat = site["start_lonlat"]
|
||||
assert 124 < lon < 132 and 33 < lat < 39 # 작업 좌표계(폴백 5186) → 경위도
|
||||
assert site["mid_lonlat"] != site["start_lonlat"]
|
||||
|
||||
|
||||
def _client(tmp_path, monkeypatch):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
async def root(_project_id):
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(ms, "_project_root", root)
|
||||
app = FastAPI()
|
||||
app.include_router(ms.router)
|
||||
return TestClient(app), f"/api/projects/{uuid4()}/quantity/milestone"
|
||||
|
||||
|
||||
def test_저장_왕복_그림(tmp_path, monkeypatch):
|
||||
client, base = _client(tmp_path, monkeypatch)
|
||||
empty = client.get(base).json()
|
||||
assert empty["data"]["routes"] == [] and empty["table"] == [] and empty["images"] == {}
|
||||
_route_csv(tmp_path, [(0, 0), (3000, 0)])
|
||||
saved = client.put(base, json=SAMPLE).json()
|
||||
assert saved["data"] == ms.Milestone.model_validate(SAMPLE).model_dump()
|
||||
assert saved["table"] == compute_milestone(SAMPLE, 1500.0) # 서버가 다시 셈
|
||||
stored = json.loads((tmp_path / "B08_Quantity/milestone/milestone.json").read_text("utf-8"))
|
||||
assert stored["routes"][0]["name"] == "거창군청" and "table" not in stored
|
||||
assert client.get(base).json() == saved
|
||||
# 그림 — 경로 하나 = 그림 하나(교체) · 내려받기 · 지우기
|
||||
img = base + "/image"
|
||||
up = client.post(img, params={"route": "r1"}, files={"file": ("a.PNG", b"png1", "image/png")})
|
||||
assert up.json() == {"name": "r1.png"}
|
||||
client.post(img, params={"route": "r1"}, files={"file": ("b.jpg", b"jpg2", "image/jpeg")})
|
||||
assert client.get(base).json()["images"] == {"r1": "r1.jpg"}
|
||||
assert client.get(img, params={"route": "r1"}).content == b"jpg2"
|
||||
assert (
|
||||
client.post(img, params={"route": "r1"}, files={"file": ("x.pdf", b"1")}).status_code == 400
|
||||
)
|
||||
assert client.get(img, params={"route": "../x"}).status_code == 400
|
||||
client.post(img, params={"route": "r2"}, files={"file": ("c.png", b"png3", "image/png")})
|
||||
client.delete(img, params={"route": "r2"})
|
||||
assert client.get(img, params={"route": "r2"}).status_code == 404
|
||||
# 경로 삭제 뒤 [저장] = 그 경로 그림도 지움
|
||||
client.post(img, params={"route": "r2"}, files={"file": ("c.png", b"png3", "image/png")})
|
||||
one = {**SAMPLE, "routes": SAMPLE["routes"][:1]}
|
||||
assert client.put(base, json=one).json()["images"] == {"r1": "r1.jpg"}
|
||||
# 겹친 id · 나쁜 값 = 거절
|
||||
twice = {**SAMPLE, "routes": [SAMPLE["routes"][0]] * 2}
|
||||
assert client.put(base, json=twice).status_code == 422
|
||||
bad = {**SAMPLE, "site": {"mid_station_m": -1, "mid_road": "비포장"}}
|
||||
assert client.put(base, json=bad).status_code == 422
|
||||
|
||||
|
||||
def _read(rel):
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_메인_탭_하위_탭_추가_삭제():
|
||||
page = _read("B08_Quantity/B08_Quantity_UI_Page.ts")
|
||||
assert '{ id: "milestone", label: "B08_Quantity_Tab_Milestone" }' in page
|
||||
assert 'if (tab === "milestone" && projectId) return renderMilestone(view, projectId);' in page
|
||||
assert 'B08_Quantity_Tab_Milestone: ["거리이정표"' in _read(
|
||||
"ui_template/ui_template_locale_b4.ts"
|
||||
)
|
||||
ui = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts")
|
||||
assert 'tab("table", "거리이정표")' in ui and "routes.map((r, i) => tab(r.id," in ui
|
||||
assert 'label: "+ 추가"' in ui and "open(addRoute(projectId))" in ui
|
||||
assert 'label: "삭제"' in ui and 'remove.disabled = current === "table"' in ui
|
||||
assert "data.routes.filter((r) => r.id !== route.id)" in ui
|
||||
assert "editOnDouble: true" in ui and "computeMilestone(liveMilestone(projectId)" in ui
|
||||
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
|
||||
assert route.count("editOnDouble: true") == 2 and "uploadImage(projectId, routeId" in route
|
||||
# 조작 = 캐시 · [저장] = 좌측 패널 한 줄에서 PUT · 자동 저장 없음
|
||||
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
|
||||
assert 'writeState("b08-milestone"' in state and 'clearState("b08-milestone"' in state
|
||||
assert state.count('method: "PUT"') == 1 and "setTimeout" not in state
|
||||
options = _read("B08_Quantity/B08_Quantity_UI_Options.ts")
|
||||
assert (
|
||||
'card("milestone", "B08_Quantity_Tab_Milestone", ...milestoneCardBody(projectId))'
|
||||
in options
|
||||
)
|
||||
assert "await saveMilestone(projectId);" in options
|
||||
registry = _read("A00_Common/b_page_state.ts")
|
||||
assert '"b08-milestone": { bucket: "draft", scope: "project" }' in registry
|
||||
main = _read("main.py")
|
||||
assert "app.include_router(b08_milestone_router, dependencies=protected_with_company)" in main
|
||||
# 짝 표기
|
||||
assert "짝 = `B08_Quantity_Milestone_Calc.ts`" in _read(
|
||||
"B08_Quantity/B08_Quantity_Milestone_Calc.py"
|
||||
)
|
||||
assert "짝 = `B08_Quantity_Milestone_Calc.py`" in _read(
|
||||
"B08_Quantity/B08_Quantity_Milestone_Calc.ts"
|
||||
)
|
||||
for name in ("UI_Milestone.ts", "UI_Milestone_Route.ts", "UI_Milestone_State.ts",
|
||||
"Router_Milestone.py"): # fmt: skip
|
||||
rel = f"B08_Quantity/B08_Quantity_{name}"
|
||||
assert len(_read(rel).splitlines()) <= 700
|
||||
@@ -18,6 +18,7 @@ export const ui_locales_b4 = {
|
||||
"Insert suggested value — KDS standard soil 0.5 m",
|
||||
],
|
||||
B08_Quantity_Tab_Hydrology: ["수리집수면적표", "Hydraulic Catchment Table"],
|
||||
B08_Quantity_Tab_Milestone: ["거리이정표", "Distance Milestones"],
|
||||
B08_Quantity_Tab_Pending: ["차례대로 만들 탭입니다.", "This tab comes later, in order."],
|
||||
B08_Quantity_Tab_Materials: ["자재", "Materials"],
|
||||
B08_Quantity_Tab_MaterialTotal: ["총괄자재수량집계표", "Total Material Quantity Table"],
|
||||
|
||||
Reference in New Issue
Block a user