From 7fdbcc4794321f3e6c835d4335848e4b9ae2a350 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 13:58:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(B08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EC=97=AC=EB=9F=AC=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=A0=90=20=E2=80=94=20bases[]=20=C2=B7=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=20to=20=C2=B7=20=EC=98=9B=20base=20=EC=98=AE=EA=B9=80=20=C2=B7?= =?UTF-8?q?=20=EC=82=AC=EC=8A=AC=20=EC=85=88(254-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 데이터 = bases[](id · to = 시점 또는 다른 기준점 · linked) · 경로 via_base 대신 to - 옛 꼴(base · via_base) = 읽을 때 · 받을 때 새 꼴로 옮김(migrate 짝 Py · TS · 저장 = 새 꼴) - 행 합 = 거래처 구간 + 기준점 사슬 구간들(방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤) - 도로 구분 = 시점에 닿는 기준점 구간 1차 · 그 밖 기준점 구간 · 거래처 → 기준점 2차 - 화면 = 기준점 여럿 등록 · 삭제(이은 상자 안 이음) · 연결 놓기 = 기준점 id · 길찾기 · 표지 사슬 - 시험 = test_254_1 새로 · 244 · 250 기대값 새 꼴로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Haul.py | 2 +- B08_Quantity/B08_Quantity_Milestone_Calc.py | 71 +++++- B08_Quantity/B08_Quantity_Milestone_Calc.ts | 142 +++++++++--- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 186 +++++++++------ B08_Quantity/B08_Quantity_Router_Milestone.py | 27 ++- B08_Quantity/B08_Quantity_UI_Milestone.ts | 6 +- .../B08_Quantity_UI_Milestone_Diagram.ts | 25 +- .../B08_Quantity_UI_Milestone_LegModal.ts | 36 ++- .../B08_Quantity_UI_Milestone_Modal.ts | 58 +++-- .../B08_Quantity_UI_Milestone_Route.ts | 13 +- .../B08_Quantity_UI_Milestone_State.ts | 29 ++- .../test_244_12_b08_milestone_base_view.py | 2 + .../test_244_13_b08_milestone_place_leg.py | 45 ++-- .../test_244_2_b08_milestone_diagram.py | 11 +- .../tester/test_244_4_b08_milestone_modal.py | 17 +- .../test_244_5_b08_milestone_base_dir.py | 14 +- .../test_244_6_b08_milestone_pan_client.py | 12 +- .../tester/test_244_7_b08_milestone_kakao.py | 22 +- .../test_244_9_b08_milestone_route_map.py | 5 +- .../test_250_1_b08_milestone_roads_width.py | 21 +- .../tester/test_254_1_b08_milestone_bases.py | 213 ++++++++++++++++++ 21 files changed, 694 insertions(+), 263 deletions(-) create mode 100644 resources/tester/test_254_1_b08_milestone_bases.py diff --git a/B08_Quantity/B08_Quantity_Haul.py b/B08_Quantity/B08_Quantity_Haul.py index 93a01af50..c3e509662 100644 --- a/B08_Quantity/B08_Quantity_Haul.py +++ b/B08_Quantity/B08_Quantity_Haul.py @@ -118,7 +118,7 @@ def with_haul_routes(data: dict[str, Any], groups: list[dict[str, Any]]) -> dict "id": f"h-{g['key']}"[:40], "name": "", "address": "", - "via_base": True, + "to": "site", "linked": False, "dir": None, "haul": g["key"], diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.py b/B08_Quantity/B08_Quantity_Milestone_Calc.py index 006196a3d..2f2195984 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.py +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.py @@ -4,14 +4,16 @@ 거울 시험 `resources/tester/test_244_1_b08_milestone.py`. 조작 중은 TS 짝이 바로 셈 · [저장] · 읽기 때 서버가 이 파일로 다시 셈. -표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면 +표 한 줄 = 경로(업체) 구간 + 기준점 사슬 구간들(254 · 거래처 `to` → 기준점 `to` → … → 시점 · +`base_chain` · 방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤) + (목적지 = 중점이면 시점 → 중점 = 중점 측점 km) · 도로 구분 셋(2차포장 · 1차포장 · 비포장)마다 합 · 포장 = 2차 + 1차 · 비포장 · 합계 = 포장 + 비포장(견본 「거리이정표」 시트 수식) · 끝수 = 소수 셋째 자리(반올림). 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7). -도로 구분(152) = 포장 종류는 선 자리로 정함(`road_of`) — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 = -1차포장 · 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 구간마다 비포장만 고름 · -포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리). +도로 구분(152 · 254) = 포장 종류는 선 자리로 정함(`road_of`) — 시점에 닿는 기준점 구간 = 1차포장 · +그 밖 기준점 구간 · 거래처 → 기준점 = 2차포장 · 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 +구간마다 비포장만 고름 · 포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리). +옛 꼴(단일 `base` · 경로 `via_base`) = 읽을 때 `migrate_milestone` 로 새 꼴(`bases[]` · `to`). """ from __future__ import annotations @@ -21,8 +23,10 @@ from typing import Any ROADS = ("2차포장", "1차포장", "포장", "비포장") KEYS = {"2차포장": "s2", "1차포장": "s1", "포장": "pv", "비포장": "un"} -# 구간 자리 → 포장 종류(152) — via = 거래처 → 기준점 · base = 기준점 → 시점 · direct = 거래처 → 시점 · mid = 시점 → 중점 +# 구간 자리 → 포장 종류(152 · 254) — via = → 기준점 · base = 기준점 → 시점 · +# direct = 거래처 → 시점 · mid = 시점 → 중점 PAVED = {"via": "2차포장", "base": "1차포장", "direct": "포장", "mid": "포장"} +SITE = "site" # 이음 대상 = 시점(그 밖 = 기준점 id) def road_of(road: str | None, kind: str) -> str: @@ -55,21 +59,64 @@ def mid_km(data: dict[str, Any], mid_default_m: float | None) -> float: return float(station or 0) / 1000 +def base_chain(data: dict[str, Any], to: str) -> list[dict[str, Any]]: + """기준점 사슬(254) — to 부터 이은 기준점을 따라 시점까지 · 방문 집합(돌기 끊음) · + 안 이은 · 없는 기준점에서 멈춤.""" + by_id = {b.get("id"): b for b in data.get("bases") or []} + out: list[dict[str, Any]] = [] + seen: set[str] = set() + while to != SITE and to not in seen: + seen.add(to) + base = by_id.get(to) + if base is None or base.get("linked", True) is False: + break + out.append(base) + to = base.get("to") or SITE + return out + + +def migrate_milestone(data: dict[str, Any]) -> dict[str, Any]: + """옛 꼴 → 새 꼴(254) — 옛 `base`(켜짐) = bases[0] id "b1" · + 경로 `via_base` = to "b1" | "site" · 새 꼴은 그대로(사본).""" + rest = {k: v for k, v in data.items() if k != "base"} + old = data.get("base") or {} + fresh = isinstance(rest.get("bases"), list) + on = not fresh and bool(old.get("enabled")) + if fresh: + bases = rest["bases"] + elif on: + keep = ("name", "address", "dir", "lon", "lat", "pos", "manual", "legs") + bases = [{**{k: old[k] for k in keep if k in old}, "id": "b1", "to": SITE, "linked": True}] + else: + bases = [] + routes = [] + for route in rest.get("routes") or []: + if not isinstance(route.get("to"), str): + via = on and route.get("via_base", True) is not False + route = {k: v for k, v in route.items() if k != "via_base"} + route["to"] = "b1" if via else SITE + routes.append(route) + return {**rest, "bases": bases, "routes": routes} + + +def _kind(owner: dict[str, Any], to_site: str, to_base: str) -> str: + return to_site if (owner.get("to") or SITE) == SITE else to_base + + def compute_milestone(data: dict[str, Any], mid_default_m: float | None) -> list[dict[str, Any]]: """표 줄 — 경로 차례 · 경로 안 줄 차례.""" + data = migrate_milestone(data) 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, "pv": 0.0, "un": 0.0} - linked = route.get("linked", True) is not False # 안 이은 거래처 = 구간 km 없음(155) - via = linked and bool(base.get("enabled")) and route.get("via_base", True) is not False - if linked: - add_legs(sums, route.get("legs"), "via" if via else "direct") - if via: - add_legs(sums, base.get("legs"), "base") + # 안 이은 거래처 = 구간 km 없음(155) · 이음 = 거래처 구간 + 기준점 사슬(254) + if route.get("linked", True) is not False: + add_legs(sums, route.get("legs"), _kind(route, "direct", "via")) + for base in base_chain(data, route.get("to") or SITE): + add_legs(sums, base.get("legs"), _kind(base, "base", "via")) if row.get("dest") == "mid": add_legs(sums, [mid], "mid") paved = sums["s2"] + sums["s1"] + sums["pv"] diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts index 5c1bac853..a26bbab10 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -6,20 +6,23 @@ * 거울 시험 `resources/tester/test_244_1_b08_milestone.py`. * 조작 중은 이 파일이 바로 셈 · [저장] · 읽기 때 서버가 파이썬 짝으로 다시 셈. * - * 표 한 줄 = 경로(업체) 구간 + (기준점 거침이면 기준점 → 시점 공통 구간) + (목적지 = 중점이면 + * 표 한 줄 = 경로(업체) 구간 + 기준점 사슬 구간들(254 · 거래처 `to` → 기준점 `to` → … → 시점 · + * `baseChain` · 방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤) + (목적지 = 중점이면 * 시점 → 중점 = 중점 측점 km) · 도로 구분 셋마다 합 · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장 · * 끝수 = 소수 셋째 자리(반올림) * 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) - * 도로 구분(152) = 포장 종류는 선 자리로 정함(`roadOf`) — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 = - * 1차포장 · 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 구간마다 비포장만 고름 · - * 포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리) + * 도로 구분(152 · 254) = 포장 종류는 선 자리로 정함(`roadOf`) — 시점에 닿는 기준점 구간 = 1차포장 · + * 그 밖 기준점 구간 · 거래처 → 기준점 = 2차포장 · 거래처 → 시점 · 시점 → 중점 = 포장 · 사용자는 + * 구간마다 비포장만 고름 · 포장 = 2차 + 1차 + 포장 · pick = 늘 거짓(옛 「도로 구분 고르기」 자리) + * 옛 꼴(단일 `base` · 경로 `via_base`) = 읽을 때 `migrateMilestone` 로 새 꼴(`bases[]` · `to`) * ========================================================================== */ export const ROADS = ["2차포장", "1차포장", "포장", "비포장"] as const; export type Road = (typeof ROADS)[number]; -/** 구간 자리(152) — 거래처 → 기준점(via) · 기준점 → 시점(base) · 거래처 → 시점(direct) · 시점 → 중점(mid) */ +/** 구간 자리(152 · 254) — 거래처 · 기준점 → 기준점(via) · 기준점 → 시점(base) · 거래처 → 시점(direct) · + * 시점 → 중점(mid) */ export type SegKind = "via" | "base" | "direct" | "mid"; -/** 포장 종류 = 선 자리로 정함(152) — 기준점이 생기면 거래처 → 기준점 = 2차 · 기준점 → 시점 = 1차 · 없으면 포장 */ +/** 포장 종류 = 선 자리로 정함(152) — → 기준점 = 2차 · 기준점 → 시점 = 1차 · 거래처 → 시점 = 포장 */ export const PAVED: Record = { via: "2차포장", base: "1차포장", @@ -36,20 +39,81 @@ export function withRoads(data: MilestoneData): MilestoneData { return { ...data, site: { ...data.site, mid_road: roadOf(data.site.mid_road, "mid") }, - base: { ...data.base, legs: fix(data.base.legs, "base") }, - routes: data.routes.map((r) => ({ - ...r, - legs: fix(r.legs, data.base.enabled && r.via_base !== false ? "via" : "direct"), - })), + bases: data.bases.map((b) => ({ ...b, legs: fix(b.legs, baseKind(b)) })), + routes: data.routes.map((r) => ({ ...r, legs: fix(r.legs, routeKind(r)) })), }; } -/** 선 자리 — from = 거래처 id · "base" · "start"(시점 → 중점) */ +/** 이음 대상 = 시점(254 · 그 밖 = 기준점 id) */ +export const SITE_ID = "site"; +const baseKind = (b: { to: string }): SegKind => (b.to === SITE_ID ? "base" : "via"); +const routeKind = (r: { to: string }): SegKind => (r.to === SITE_ID ? "direct" : "via"); + +/** 선 자리 — from = 거래처 id · 기준점 id · "start"(시점 → 중점) */ export function segKind(data: MilestoneData, from: string): SegKind { if (from === "start") return "mid"; - if (from === "base") return "base"; + const base = data.bases.find((b) => b.id === from); + if (base) return baseKind(base); const route = data.routes.find((r) => r.id === from); - return data.base.enabled && route?.via_base !== false ? "via" : "direct"; + return route ? routeKind(route) : "direct"; +} + +/** 기준점 사슬(254) — to 부터 이은 기준점을 따라 시점까지 · 방문 집합(돌기 끊음) · 안 이은 · 없는 기준점에서 멈춤 */ +export function baseChain(data: MilestoneData, to: string): MilestoneBase[] { + const out: MilestoneBase[] = []; + const seen = new Set(); + let id = to; + while (id !== SITE_ID && !seen.has(id)) { + seen.add(id); + const base = data.bases.find((b) => b.id === id); + if (!base || base.linked === false) break; + out.push(base); + id = base.to; + } + return out; +} + +type OldBase = Partial & { enabled?: boolean }; +type OldData = Partial> & { + base?: OldBase | null; + routes?: Array & { via_base?: boolean }>; +}; + +/** 옛 꼴 → 새 꼴(254) — 옛 `base`(켜짐) = bases[0] id "b1" · 경로 `via_base` = to "b1" | "site" · + * 새 꼴은 그대로(사본) */ +export function migrateMilestone(data: OldData | MilestoneData): MilestoneData { + const { base: old, ...rest } = data as OldData; + const fresh = Array.isArray(rest.bases); + const on = !fresh && !!old?.enabled; + const bases: MilestoneBase[] = fresh + ? rest.bases! + : on + ? [ + { + id: "b1", + name: old!.name ?? "", + address: old!.address ?? "", + dir: old!.dir ?? null, + lon: old!.lon ?? null, + lat: old!.lat ?? null, + pos: old!.pos ?? null, + manual: !!old!.manual, + legs: old!.legs ?? [], + to: SITE_ID, + linked: true, + }, + ] + : []; + const routes = (rest.routes ?? []).map((r) => { + if (typeof r.to === "string") return r as MilestoneRoute; + const { via_base, ...keep } = r; + return { ...keep, to: on && via_base !== false ? "b1" : SITE_ID } as MilestoneRoute; + }); + return { + site: rest.site ?? { mid_station_m: null, mid_road: "비포장" }, + bases, + routes, + }; } /** 8방 — 기준점(없으면 공사지) 기준 지도 방위 */ export const DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const; @@ -76,8 +140,8 @@ export interface MilestoneRoute { /** 업체 이름 = 하위 탭 이름 */ name: string; address: string; - /** 기준점 거침(기준점 없으면 무시 · 시점으로 바로) */ - via_base: boolean; + /** 잇는 곳(254) — "site"(시점으로 바로) · 기준점 id */ + to: string; dir: Dir | null; lon?: number | null; lat?: number | null; @@ -91,25 +155,32 @@ export interface MilestoneRoute { haul?: string | null; /** 단가연결(251 · dg · sg · logic 꼴 · 정본) */ ref?: Record | null; - /** 업체 → 기준점(거침) · 시점 구간 */ + /** 업체 → to(기준점 · 시점) 구간 */ legs: Leg[]; rows: MilestoneRow[]; } +/** 기준점(254 · 여럿) — 거래처가 거쳐 가는 자리 · to = 시점 또는 다른 기준점 */ +export interface MilestoneBase { + id: string; + name: string; + address: string; + dir: Dir | null; + lon?: number | null; + lat?: number | null; + pos?: [number, number] | null; + manual?: boolean; + /** 이 기준점 → to 구간 */ + legs: Leg[]; + /** 잇는 곳 — "site"(시점) · 다른 기준점 id */ + to: string; + /** false = 아직 선 안 이음(요소 추가 뒤) · 없음 = 이음 */ + linked?: boolean; +} + 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; - pos?: [number, number] | null; - manual?: boolean; - /** 기준점 → 시점 공통 구간 */ - legs: Leg[]; - }; + bases: MilestoneBase[]; routes: MilestoneRoute[]; } @@ -153,19 +224,20 @@ export function midKm(data: MilestoneData, midDefaultM: number | null): number { /** 표 줄 — 경로 차례 · 경로 안 줄 차례 */ export function computeMilestone( - data: MilestoneData, + raw: MilestoneData, midDefaultM: number | null, ): MilestoneTableRow[] { - const base = data.base; + const data = migrateMilestone(raw); 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, pv: 0, un: 0 }; - const linked = route.linked !== false; // 안 이은 거래처 = 구간 km 없음(155) - const via = linked && !!base?.enabled && route.via_base !== false; - if (linked) addLegs(sums, route.legs, via ? "via" : "direct"); - if (via) addLegs(sums, base.legs, "base"); + if (route.linked !== false) { + // 안 이은 거래처 = 구간 km 없음(155) · 이음 = 거래처 구간 + 기준점 사슬(254) + addLegs(sums, route.legs, routeKind(route)); + for (const b of baseChain(data, route.to)) addLegs(sums, b.legs, baseKind(b)); + } if (row.dest === "mid") addLegs(sums, [mid], "mid"); const paved = sums.s2 + sums.s1 + sums.pv; out.push({ diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index d3f7016da..f2de77c52 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -18,7 +18,7 @@ * 다른 선 · 다른 글을 피한 자리(`placeLabels`) · 상자 안 글 = 안쪽 여백 넘으면 줄임표 * ========================================================================== */ -import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc"; +import type { Dir, Leg, MilestoneBase, MilestoneData } from "./B08_Quantity_Milestone_Calc"; export interface DiagramBox { id: string; @@ -201,7 +201,7 @@ export function setAutoKm(legs: Leg[], km: number): Leg[] { } export interface KmSegment { - /** route = 거래처 구간 · base = 기준점 → 시점 공통 구간 */ + /** route = 거래처 구간 · base = 기준점 구간(→ 시점 · 다른 기준점) */ key: "route" | "base"; text: string; from: [number, number] | null; @@ -211,40 +211,64 @@ export interface KmSegment { const pointOf = (p: { lon?: number | null; lat?: number | null }): [number, number] | null => p.lon != null && p.lat != null ? [p.lon, p.lat] : null; -/** 구간 한 줄(244-13 연결선) — from = 거래처 id(거래처 → 기준점(거침) · 시점) · "base"(기준점 → 시점) · - * 없는 구간 = null(시점 → 중점 = 손 입력 · 길찾기 안 함) */ +/** 이음 대상 = 시점(Calc `SITE_ID` 와 같음 · 형 import 만) */ +const TO_SITE = "site"; +const baseOf = (data: MilestoneData, id: string): MilestoneBase | undefined => + data.bases.find((b) => b.id === id); +/** 구간 주인 — 기준점 · 거래처(from id) */ +export const ownerOf = (data: MilestoneData, from: string) => + baseOf(data, from) ?? data.routes.find((r) => r.id === from); + +/** 기준점 사슬(254 · Calc `baseChain` 과 같음) — to 부터 시점까지 · 돌기 · 안 이음 · 없음에서 멈춤 */ +export function chainOf(data: MilestoneData, to: string): MilestoneBase[] { + const out: MilestoneBase[] = []; + const seen = new Set(); + for (let id = to; id !== TO_SITE && !seen.has(id);) { + seen.add(id); + const base = baseOf(data, id); + if (!base || base.linked === false) break; + out.push(base); + id = base.to; + } + return out; +} + +/** 돌기(254) — 기준점 from 을 to 에 이으면 to 사슬이 from 으로 돌아옴(A → B → A · 이음 여부 무관) */ +export function makesLoop(data: MilestoneData, from: string, to: string): boolean { + const seen = new Set(); + for (let id = to; id !== TO_SITE && !seen.has(id); id = baseOf(data, id)?.to ?? TO_SITE) { + if (id === from) return true; + seen.add(id); + } + return false; +} + +/** 구간 한 줄(244-13 연결선) — from = 거래처 id · 기준점 id(→ to = 시점 · 기준점) · + * 없는 구간 = null(시점 → 중점 = 손 입력 · 길찾기 안 함 · 안 이음) */ export function segmentEnds(data: MilestoneData, from: string, start: LonLat): KmSegment | null { - const baseName = data.base.name || "기준점"; - if (from === "base") - return data.base.enabled - ? { key: "base", text: `${baseName} → 시점`, from: pointOf(data.base), to: start ?? null } - : null; - const route = data.routes.find((r) => r.id === from); - if (!route || route.linked === false) return null; // 155 안 이은 거래처 = 구간 없음 - const viaBase = data.base.enabled && route.via_base; + const base = baseOf(data, from); + const owner = base ?? data.routes.find((r) => r.id === from); + if (!owner || owner.linked === false) return null; // 155 안 이은 상자 = 구간 없음 + const target = owner.to === TO_SITE ? undefined : baseOf(data, owner.to); + if (owner.to !== TO_SITE && !target) return null; return { - key: "route", - text: `${route.name || "거래처"} → ${viaBase ? baseName : "시점"}`, - from: pointOf(route), - to: viaBase ? pointOf(data.base) : (start ?? null), + key: base ? "base" : "route", + text: `${owner.name || (base ? "기준점" : "거래처")} → ${target ? target.name || "기준점" : "시점"}`, + from: pointOf(owner), + to: target ? pointOf(target) : (start ?? null), }; } -/** 기준점 거쳐 시점 들어가는 구간(244-13 · 도로 구분 2차 · 1차 · 비포장) — 기준점 → 시점 · 거침 거래처 */ -export function chainVia(data: MilestoneData, from: string): boolean { - if (!data.base.enabled || from === "start") return false; - return from === "base" || !!data.routes.find((r) => r.id === from)?.via_base; -} - -/** [길찾기] 구간 — 거래처 → 기준점 · 기준점 → 시점(거침) · 기준점 없으면 거래처 → 시점 +/** [길찾기] 구간 — 거래처 → to · 그 뒤 기준점 사슬 구간들(254) * (시점 → 중점 = 노선 측점 거리 그대로 · 길찾기 안 함) */ export function kmSegments(data: MilestoneData, routeId: string, start: LonLat): KmSegment[] { const first = segmentEnds(data, routeId, start); - if (!first) return []; - return chainVia(data, routeId) ? [first, segmentEnds(data, "base", start)!] : [first]; + const route = data.routes.find((r) => r.id === routeId); + if (!first || !route) return []; + return [first, ...chainOf(data, route.to).map((b) => segmentEnds(data, b.id, start)!)]; } -/** 카카오 지도 표지(244-9) — 거래처 · 기준점(거침 · 좌표 있을 때) · 시점 */ +/** 카카오 지도 표지(244-9) — 거래처 · 기준점 사슬(좌표 있을 때) · 시점 */ export function mapMarks( data: MilestoneData, routeId: string, @@ -257,19 +281,15 @@ export function mapMarks( if (p) out.push({ name, lon: p[0], lat: p[1] }); }; add(route.name || "거래처", pointOf(route)); - if (data.base.enabled && route.via_base) add(data.base.name || "기준점", pointOf(data.base)); + if (route.linked !== false) + for (const b of chainOf(data, route.to)) add(b.name || "기준점", pointOf(b)); add("시점", start ?? null); return out; } -/** 자동 km 를 그 구간에(사용자 km 는 그대로) */ -export function putAutoKm( - data: MilestoneData, - routeId: string, - key: KmSegment["key"], - km: number, -): MilestoneData { - const legs = key === "base" ? data.base.legs : data.routes.find((r) => r.id === routeId)?.legs; +/** 자동 km 를 그 구간에(from = 거래처 · 기준점 id · 사용자 km 는 그대로) */ +export function putAutoKm(data: MilestoneData, from: string, km: number): MilestoneData { + const legs = ownerOf(data, from)?.legs; if (legs) setAutoKm(legs, km); return data; } @@ -363,8 +383,7 @@ export function layoutMilestone( }; /** 상자 놓기 — 상자 안 글 줄임(안쪽 여백) · out = 바깥 방향(겹치면 GAP 날 때까지 그쪽으로 밀어 냄) */ // 153 사용자가 끌어 놓은 자리(왼 위 · 그림 좌표) — 있으면 방위 자리 · 밀어 냄 대신 그 자리 - const posOf = (id: string): [number, number] | null | undefined => - id === "base" ? data.base.pos : data.routes.find((r) => r.id === id)?.pos; + const posOf = (id: string): [number, number] | null | undefined => ownerOf(data, id)?.pos; const put = (box: DiagramBox, out?: [number, number]): void => { const inner = box.w - 2 * INSET; box.label = clip(box.label, inner, FONT.label); @@ -411,31 +430,60 @@ export function layoutMilestone( line("start", "mid", [{ road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }]); const startLL = site?.start_lonlat ?? null; - const useBase = data.base.enabled; - const baseLL: LonLat = - data.base.lon != null && data.base.lat != null ? [data.base.lon, data.base.lat] : null; - let baseDeg = 270; - if (useBase) { - baseDeg = placeDeg({ dir: null, lon: data.base.lon, lat: data.base.lat }, startLL) ?? 270; - const [ux, uy] = unit(baseDeg); - const cx = ux * R_BASE; - const cy = uy * R_BASE; + const llOf = (p?: { lon?: number | null; lat?: number | null }): LonLat => + p && p.lon != null && p.lat != null ? [p.lon, p.lat] : null; + const used = new Set(); + const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`; + /** 상자 id → 잇는 곳에서 본 방위(기준점 둘레 빈 방위 = 그 방위에서 돌림 · 되돌아가는 쪽 뺌) */ + const degOf = new Map(); + let auto = 0; + /** 방위 안 정한 상자 — 빈 8방 차례(기준점 둘레 = 잇는 곳 반대편부터 · 잇는 곳으로 향하는 쪽 뺌) */ + const free = (anchor: string): number => { + const turn = anchor === "site" ? 0 : (degOf.get(anchor) ?? 0); + const blocked = anchor === "site" ? -1 : turn + 180; + const cands = AUTO.map((d) => (DIR_DEG[d] + turn) % 360).filter( + (c) => keyOf(anchor, c) !== keyOf(anchor, blocked), + ); + return cands.find((c) => !used.has(keyOf(anchor, c))) ?? cands[auto++ % cands.length]; + }; + /** 잇는 곳 상자 — 놓인 기준점이면 그 id · 아니면 공사지 */ + const anchorOf = (to: string): string => (to !== "site" && byId.has(to) ? to : "site"); + + // 기준점(254 · 여럿) — 잇는 곳(시점 · 다른 기준점) 기준 방위(좌표 → 방위각 · 없으면 서부터 빈 방위) · + // 잇는 곳이 먼저 놓이게 사슬 차례 · 돌기 = 공사지 둘레 + const waiting = [...data.bases]; + while (waiting.length) { + let i = waiting.findIndex( + (b) => b.linked === false || b.to === "site" || byId.has(b.to) || !ownerOf(data, b.to), + ); + if (i < 0) i = 0; + const [b] = waiting.splice(i, 1); + const anchor = b.linked === false ? "site" : anchorOf(b.to); + const anchorLL = anchor === "site" ? startLL : (llOf(ownerOf(data, anchor)) ?? startLL); + const deg = + placeDeg({ dir: null, lon: b.lon, lat: b.lat }, anchorLL) ?? + (anchor === "site" && !used.has(keyOf("site", 270)) ? 270 : free(anchor)); + used.add(keyOf(anchor, deg)); + degOf.set(b.id, deg); + const [ax, ay] = center(anchor); + const [ux, uy] = unit(deg); put( { - id: "base", + id: b.id, kind: "base", - x: cx - BOX.w / 2, - y: cy - BOX.h / 2, + x: ax + ux * R_BASE - BOX.w / 2, + y: ay + uy * R_BASE - BOX.h / 2, w: BOX.w, h: BOX.h, - label: data.base.name || "기준점", + label: b.name || "기준점", }, [ux, uy], ); - line("base", "start", data.base.legs); + if (b.linked !== false && (b.to === "site" || byId.has(b.to))) + line(b.id, b.to === "site" ? "start" : b.to, b.legs); } - // 거래처 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히 + // 거래처 — 잇는 곳(기준점 · 공사지) 기준 방위 · 같은 방위끼리 옆으로 나란히 const groups = new Map< string, Array<{ @@ -446,25 +494,15 @@ export function layoutMilestone( anchor: string; deg: number; legs: Leg[]; + linked: boolean; }> >(); - const used = new Set(); - const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`; const items = data.routes.map((r) => { - const anchor = useBase && r.via_base ? "base" : "site"; - return { r, anchor, deg: placeDeg(r, anchor === "base" ? (baseLL ?? startLL) : startLL) }; + const anchor = anchorOf(r.to); + const anchorLL = anchor === "site" ? startLL : (llOf(ownerOf(data, anchor)) ?? startLL); + return { r, anchor, deg: placeDeg(r, anchorLL) }; }); for (const it of items) if (it.deg !== null) used.add(keyOf(it.anchor, it.deg)); - let auto = 0; - /** 방위 안 고른 거래처 — 빈 8방 차례(기준점 쪽 = 공사지 반대편부터 · 기준점 · 공사지로 향하는 쪽 뺌) */ - const free = (anchor: string): number => { - const turn = anchor === "base" ? baseDeg : 0; - const blocked = anchor === "base" ? baseDeg + 180 : useBase ? baseDeg : -1; - const cands = AUTO.map((d) => (DIR_DEG[d] + turn) % 360).filter( - (c) => keyOf(anchor, c) !== keyOf(anchor, blocked), - ); - return cands.find((c) => !used.has(keyOf(anchor, c))) ?? cands[auto++ % cands.length]; - }; for (const { r, anchor, deg } of items) { const d = deg ?? free(anchor); used.add(keyOf(anchor, d)); @@ -479,11 +517,13 @@ export function layoutMilestone( anchor, deg: d, legs: r.legs, + // 155 안 이은 거래처 = 선 없음(연결 점으로 이음) · 없는 기준점에 이음 = 선 없음 + linked: r.linked !== false && (r.to === "site" || anchor === r.to), }); } for (const group of groups.values()) { group.forEach((g, k) => { - const [ax, ay] = center(g.anchor === "base" ? "base" : "site"); + const [ax, ay] = center(g.anchor); const [ux, uy] = unit(g.deg); // 나란히 = 방위에 직각 · 상자 직각 폭 + 여유만큼(④) const spread = Math.abs(ux) * BOX.h + Math.abs(uy) * BOX.w + GAP; @@ -506,9 +546,7 @@ export function layoutMilestone( }, [ux, uy], ); - // 155 안 이은 거래처 = 선 없음(연결 점으로 이음) - if (data.routes.find((r) => r.id === g.id)?.linked !== false) - line(g.id, g.anchor === "base" ? "base" : "start", g.legs); + if (g.linked) line(g.id, g.anchor === "site" ? "start" : g.anchor, g.legs); }); } @@ -530,11 +568,10 @@ export function layoutMilestone( }; } -/** 선 글 한 줄 → 그 구간 — from = 거래처 id(거래처 구간) · "base"(공통 구간) · "start"(시점 → 중점) */ +/** 선 글 한 줄 → 그 구간 — from = 거래처 id · 기준점 id · "start"(시점 → 중점) */ export function legAt(data: MilestoneData, from: string, i: number, midKm: number): Leg | null { if (from === "start") return { road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }; - const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs; - return legs?.[i] ?? null; + return ownerOf(data, from)?.legs[i] ?? null; } /** 선 글에서 고친 값 → 데이터(시점 → 중점 km = 중점 측점 m) · 고친 것만 */ @@ -550,8 +587,7 @@ export function setLeg( data.site.mid_station_m = patch.km == null ? null : Math.round(patch.km * 1000 * 1000) / 1000; return data; } - const legs = from === "base" ? data.base.legs : data.routes.find((r) => r.id === from)?.legs; - const leg = legs?.[i]; + const leg = ownerOf(data, from)?.legs[i]; if (leg) Object.assign(leg, patch); return data; } diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index e68cccdbc..5ecf8d0de 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -27,6 +27,8 @@ 공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례 (수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례). 표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝). +기준점 = `bases[]`(254 · 여럿 · to = 시점 또는 다른 기준점) · 경로 `to` — 옛 `base` · `via_base` 는 +읽을 때 · 받을 때 새 꼴로 옮김(`migrate_milestone` · 저장 = 새 꼴). """ from __future__ import annotations @@ -50,7 +52,7 @@ from B01_Dashboard.B01_Dashboard_Map import reverse_geocode from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id from B03_FileInput.B03_FileInput_Engine import save_upload_stream from B08_Quantity.B08_Quantity_Haul import haul_groups, with_haul_routes -from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, r3 +from B08_Quantity.B08_Quantity_Milestone_Calc import SITE, compute_milestone, migrate_milestone, r3 from B08_Quantity.B08_Quantity_Milestone_Capture import make_route_image from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets @@ -71,6 +73,7 @@ DATA_FILE = "milestone.json" AUTO = "auto" # 자동 그림 하위 폴더(244-11) IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$" +BOX_IDS = {SITE, "start", "mid"} # 다이어그램 공사지 상자 id — 기준점 · 경로 id 로 못 씀 Road = Literal["2차포장", "1차포장", "포장", "비포장"] # 포장 = 기준점 안 거친 구간(244-13) Dir = Literal["N", "NE", "E", "SE", "S", "SW", "W", "NW"] @@ -102,7 +105,7 @@ class Place(BaseModel): class Route(Place): id: str = Field(pattern=ROUTE_ID) - via_base: bool = True + to: str = Field(default=SITE, pattern=ROUTE_ID) # 254 잇는 곳 = "site"(시점) · 기준점 id # 251 운반 묶음 키(총괄자재 · B09 운반 줄과 이음) · 단가연결(dg · sg · logic 꼴 · 정본) haul: str | None = Field(default=None, max_length=60, pattern=r"^[A-Za-z0-9_-]+$") ref: dict[str, Any] | None = None @@ -111,7 +114,11 @@ class Route(Place): class Base(Place): - enabled: bool = False + """기준점(254 · 여럿) — to = 시점 또는 다른 기준점 id(돌기는 화면이 막고 셈이 끊음).""" + + id: str = Field(pattern=ROUTE_ID) + to: str = Field(default=SITE, pattern=ROUTE_ID) + linked: bool = True # False = 아직 선 안 이음(요소 추가 뒤) class Site(BaseModel): @@ -121,14 +128,22 @@ class Site(BaseModel): class Milestone(BaseModel): site: Site = Field(default_factory=Site) - base: Base = Field(default_factory=Base) + bases: list[Base] = Field(default_factory=list, max_length=20) routes: list[Route] = Field(default_factory=list, max_length=100) + @model_validator(mode="before") + @classmethod + def _migrate(cls, data: Any) -> Any: + """옛 꼴(단일 base · via_base) → 새 꼴(254).""" + return migrate_milestone(data) if isinstance(data, dict) else data + @model_validator(mode="after") def _unique_ids(self) -> Milestone: - ids = [route.id for route in self.routes] + ids = [b.id for b in self.bases] + [route.id for route in self.routes] if len(ids) != len(set(ids)): - raise ValueError("경로 id 가 겹칩니다.") + raise ValueError("경로 · 기준점 id 가 겹칩니다.") + if BOX_IDS & set(ids): + raise ValueError("site · start · mid 는 id 로 못 씁니다.") return self diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index 148f27c34..3de113da2 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -87,7 +87,7 @@ export function addRoute(projectId: string, init: Partial = {}): id, name: `거래처 ${data.routes.length + 1}`, address: "", - via_base: true, + to: "site", dir: null, legs: [{ road: null, km: null }], // 도로 구분 = 사용자 고름(244-7) rows: [{ item: "", dest: "start", note: "" }], @@ -168,12 +168,12 @@ function tableView(projectId: string): MilestoneView { const data = liveMilestone(projectId); const rows = computeMilestone(data, siteInfo()?.mid_default_m ?? null); // 기준점 생김 · 없앰 = 열 바뀜 → 표 새로 - const next = `${data.base.enabled}:${rows.some((r) => r.pv > 0)}`; + const next = `${data.bases.length > 0}:${rows.some((r) => r.pv > 0)}`; if (next !== mode) { mode = next; const fresh = makeTable( milestoneColumns( - data.base.enabled, + data.bases.length > 0, rows.some((r) => r.pv > 0), ), ); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index e7595d8a1..ee2fe6286 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -28,6 +28,7 @@ import { import { layoutMilestone, legAt, + ownerOf, panContent, panStart, setLeg, @@ -158,7 +159,7 @@ export function createDiagram( site: () => openSiteInfo(projectId), start: () => openSiteInfo(projectId), mid: () => openSiteInfo(projectId), - base: () => openPlaceModal(projectId, { add: false, id: "base" }), + base: (id) => openPlaceModal(projectId, { add: false, id }), }; /** 153 상자 끌어 옮김 — 4px 넘게 움직이면 끌기(누름 = 모달은 그대로) · 놓으면 그 자리 저장(캐시 · [저장] 때 서버) @@ -189,7 +190,7 @@ export function createDiagram( Math.round(box.y + (e.clientY - down.clientY) / k), ]; const fresh = liveMilestone(projectId); - const target = box.id === "base" ? fresh.base : fresh.routes.find((r) => r.id === box.id); + const target = ownerOf(fresh, box.id); if (!target) return; target.pos = pos; userZoomed = true; // 옮긴 뒤 배율 그대로 @@ -200,8 +201,9 @@ export function createDiagram( }); }; - /** 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 상자 = 기준점 거침 · 공사지(시점 · 중점) = 시점으로 바로 · - * 빈 곳 · 같은 연결 = 취소 · 바뀌면 그 거래처 구간 새로(km 비움) + 길찾기 한 번(km · 경로 그림) */ + /** 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 상자 = 그 기준점 거침(254 · to = 기준점 id) · + * 공사지(시점 · 중점) = 시점으로 바로 · 빈 곳 · 같은 연결 = 취소 · 바뀌면 그 거래처 구간 새로(km 비움) + + * 길찾기 한 번(km · 경로 그림) */ const connectDot = (box: DiagramBox, shift: SVGGElement): SVGCircleElement => { const cx = box.x + box.w; const cy = box.y + box.h / 2; @@ -221,12 +223,13 @@ export function createDiagram( const wire = svg("line", { class: "b08-milestone__wire", x1: cx, y1: cy, x2: cx, y2: cy }); shift.append(wire); let over: Element | null = null; - const targetOf = (e: PointerEvent): "base" | "site" | null => { + /** 놓을 곳 — 기준점 id · "site"(공사지 · 시점 · 중점) · 없음 */ + const targetOf = (e: PointerEvent): string | null => { const hit = document.elementFromPoint(e.clientX, e.clientY); const g = hit?.closest("[data-b08-milestone-box]"); const id = g?.dataset.b08MilestoneBox; - if (id === "base") return "base"; - return id === "site" || id === "start" || id === "mid" ? "site" : null; + if (id === "site" || id === "start" || id === "mid") return "site"; + return id && liveMilestone(projectId).bases.some((b) => b.id === id) ? id : null; }; const move = (e: PointerEvent): void => { const [x, y] = toLocal(e); @@ -249,10 +252,8 @@ export function createDiagram( const fresh = liveMilestone(projectId); const route = fresh.routes.find((r) => r.id === box.id); if (!t || !route) return; - const via = t === "base"; - const now = route.linked === false ? null : fresh.base.enabled && route.via_base !== false; - if (via === now) return; // 같은 연결 = 그대로 - route.via_base = via; + if (route.linked !== false && route.to === t) return; // 같은 연결 = 그대로 + route.to = t; route.linked = true; // 155 요소 추가 뒤 첫 이음 route.legs = [{ road: null, km: null }]; // 옛 구간 km = 다른 길 — 새로 길찾기 putMilestone(projectId, fresh); @@ -270,7 +271,7 @@ export function createDiagram( variant: "ghost", onClick: () => { const fresh = liveMilestone(projectId); - fresh.base.pos = null; + fresh.bases.forEach((b) => (b.pos = null)); fresh.routes.forEach((r) => (r.pos = null)); userZoomed = false; putMilestone(projectId, fresh); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts index 988a48bf3..0f556f114 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts @@ -3,8 +3,8 @@ * 거리이정표 연결선 모달(PLAN 244-13 · 사용자 147번 8) — 다이어그램 선 · 선 글 누름. * * 값 = 도로 구분 ▾ · 거리(km) 구간 줄(빈칸 = [길찾기] 자동 값 · 사용자 값 우선 · 244-7) - * 도로 구분(152) = 포장 종류는 선 자리로 정함 — 거래처 → 기준점 = 2차포장 · 기준점 → 시점 = 1차포장 · - * 기준점 없이 거래처 → 시점 · 시점 → 중점 = 포장 · ▾ 는 그 포장 · 비포장 둘(`roadOptions` · `roadOf`) + * 도로 구분(152 · 254) = 포장 종류는 선 자리로 정함 — → 기준점 = 2차포장 · 기준점 → 시점 = 1차포장 · + * 거래처 → 시점 · 시점 → 중점 = 포장 · ▾ 는 그 포장 · 비포장 둘(`roadOptions` · `roadOf`) * 아래 = 다음(카카오) 지도 경로(그 구간 두 끝 · 마지막 길찾기 길 선 · 없으면 열 때 한 번 받음 · 자동 그림 없음) * 거래처 · 기준점 표지 끌어 옮김 = 소재지 고침(좌표 → VWorld 주소) + 그 구간 다시 길찾기 · 시점 = 노선(고정) * 기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리 손 입력 · 시점 → 중점 = 늘 손 입력(길찾기 · 지도 없음) @@ -22,7 +22,7 @@ import { type Road, type SegKind, } from "./B08_Quantity_Milestone_Calc"; -import { chainVia, segmentEnds, setLeg } from "./B08_Quantity_Milestone_Layout"; +import { ownerOf, segmentEnds, setLeg } from "./B08_Quantity_Milestone_Layout"; import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; import { toastKm } from "./B08_Quantity_UI_Milestone_Modal"; import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; @@ -144,7 +144,7 @@ function openMidLeg(projectId: string): void { }); } -/** 연결선 누름 — from = 거래처 id(거래처 → 기준점 · 시점) · "base"(기준점 → 시점) · "start"(시점 → 중점) */ +/** 연결선 누름 — from = 거래처 · 기준점 id(→ 기준점 · 시점 · 254) · "start"(시점 → 중점) */ export function openLegInfo(projectId: string, from: string): void { if (from === "start") return openMidLeg(projectId); const start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null; @@ -155,11 +155,8 @@ export function openLegInfo(projectId: string, from: string): void { closeLabel: "닫기", dialogClass: "b08-milestone__modal", mount: (body, close, actions) => { - const legsOf = (): Leg[] => { - const d = liveMilestone(projectId); - return (from === "base" ? d.base.legs : d.routes.find((r) => r.id === from)?.legs) ?? []; - }; - const via = chainVia(liveMilestone(projectId), from); + const legsOf = (): Leg[] => ownerOf(liveMilestone(projectId), from)?.legs ?? []; + const isBase = liveMilestone(projectId).bases.some((b) => b.id === from); const kind = segKind(liveMilestone(projectId), from); let legs = legsEditor(legsOf(), kind); const status = hint(""); @@ -167,7 +164,7 @@ export function openLegInfo(projectId: string, from: string): void { /** 모달 구간 값 → 캐시(길찾기 · 옮김 앞) */ const apply = (): void => { const fresh = liveMilestone(projectId); - const target = from === "base" ? fresh.base : fresh.routes.find((r) => r.id === from); + const target = ownerOf(fresh, from); if (!target) return; target.legs = legs.value(); putMilestone(projectId, fresh); @@ -177,18 +174,20 @@ export function openLegInfo(projectId: string, from: string): void { legs.root.replaceWith(next.root); legs = next; }; + /** 표지 = 그 구간 두 끝(주인 · to = 기준점 또는 시점) */ const pins = (): MapPin[] => { const d = liveMilestone(projectId); - const route = d.routes.find((r) => r.id === from); + const owner = ownerOf(d, from); const out: MapPin[] = []; const add = (key: string, name: string, lon?: number | null, lat?: number | null): void => { if (lon != null && lat != null) out.push({ key, name, lon, lat, movable: key !== "start" }); }; - if (route) add(route.id, route.name || "거래처", route.lon, route.lat); - if (from === "base" || via) add("base", d.base.name || "기준점", d.base.lon, d.base.lat); + if (!owner) return out; + add(owner.id, owner.name || (isBase ? "기준점" : "거래처"), owner.lon, owner.lat); + const target = d.bases.find((b) => b.id === owner.to); + if (target) add(target.id, target.name || "기준점", target.lon, target.lat); else add("start", "시점", start()?.[0], start()?.[1]); - if (from === "base") add("start", "시점", start()?.[0], start()?.[1]); return out; }; const drawMap = (): void => { @@ -214,7 +213,7 @@ export function openLegInfo(projectId: string, from: string): void { apply(); void reverseAddress(projectId, lon, lat).then((address) => { const fresh = liveMilestone(projectId); - const target = key === "base" ? fresh.base : fresh.routes.find((r) => r.id === key); + const target = ownerOf(fresh, key); if (!target) return; Object.assign(target, { lon, lat, address }); putMilestone(projectId, fresh); @@ -242,14 +241,13 @@ export function openLegInfo(projectId: string, from: string): void { save.dataset.b08MilestoneLegSave = ""; actions.append(find, save); // 155 [수기 입력] — 카카오 길찾기 연결 끊음(자동 값 지움 · 길찾기 · 지도 숨김 · km 손 입력) · 풀면 다시 길찾기 - const owner = (d = liveMilestone(projectId)) => - from === "base" ? d.base : d.routes.find((r) => r.id === from); + const owner = (d = liveMilestone(projectId)) => ownerOf(d, from); const manual = createCheckbox("수기 입력 — 카카오 길찾기 안 함(거리 손 입력)"); manual.input.checked = !!owner()?.manual; manual.input.dataset.b08MilestoneManual = ""; const auto = hint( - from === "base" - ? "기준점 → 시점 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)" + isBase + ? "기준점 구간 = 도로가 안 이어질 수 있음 — 거리를 손으로 넣어도 됨(빈칸 = 길찾기 자동 값)" : "빈칸 = 길찾기 자동 값 · 넣은 값이 우선", ); const mapHead = el("h4", { text: "경로 — 다음(카카오) 지도" }); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 1942871d0..726387ccb 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -8,7 +8,8 @@ * 상자 하나 = 항목 하나(149 · [+ 줄] 없음 · 같은 거래처라도 항목마다 따로 등록) · 경로 값은 연결선 모달 * 연결(기준점 거침 · 시점으로 바로) = 다이어그램 연결 점 ● 끌기(154 · 155 · 등록 = 상자만 · 선 없음) * 열기 = 다이어그램 [요소 추가](155 · 공사지 · 기준점 [+] 없앰) - * 분류 기준위치 = 기준점(하나 · 항목 · 목적지 없음 · 있으면 고르기 막음) · 방위 = 소재지 좌표로 자동 자리 + * 분류 기준위치 = 기준점(254 · 여럿 · 새 `bases` 원소 · 항목 · 목적지 없음 · 등록 = 상자만 · 연결 점으로 이음) · + * 방위 = 소재지 좌표로 자동 자리 · 기준점 [삭제] = 그 기준점에 이은 거래처 · 기준점은 안 이음으로 * 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번 * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 * 조작 = 캐시 · [저장] 때 서버 @@ -87,20 +88,21 @@ function rowField(r: MilestoneRow, kind: "route" | "base") { export type PlaceTarget = { add: true } | { add: false; id: string }; -/** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id "base" = 기준점 */ +/** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id = 거래처 · 기준점 id */ export function openPlaceModal( projectId: string, target: PlaceTarget, addRoute?: (init: Partial) => string, ): void { const data = liveMilestone(projectId); - const editBase = !target.add && target.id === "base"; + const base = target.add ? undefined : data.bases.find((b) => b.id === target.id); + const editBase = !!base; const route = !target.add && !editBase ? data.routes.find((r) => r.id === target.id) : undefined; if (!target.add && !editBase && !route) return; const title = target.add ? "요소 추가 — 기준점 · 거래처" - : editBase - ? `기준점 — ${data.base.name || "기준점"}` + : base + ? `기준점 — ${base.name || "기준점"}` : `항목 정보 — ${route!.name || "거래처"}`; openModal({ title, @@ -118,20 +120,14 @@ export function openPlaceModal( }); sort.select.value = kind; sort.select.disabled = !target.add; - // 기준점 = 하나 — 이미 있으면 [+]에서 기준위치 못 고름(기준점 상자에서 고침) - const baseOption = sort.select.querySelector('option[value="base"]'); - if (target.add && data.base.enabled && baseOption) { - baseOption.disabled = true; - baseOption.textContent = "기준위치(있음 — 기준점 상자에서 수정)"; - } sort.select.dataset.b08MilestoneAdd = "sort"; const name = createInputField({ label: "거래처 이름", compact: true }); - name.input.value = editBase ? data.base.name : (route?.name ?? ""); + name.input.value = base ? base.name : (route?.name ?? ""); name.input.dataset.b08MilestoneAdd = "company"; const nameLabel = name.root.querySelector(".ui-field__label"); // 상자 하나 = 항목 하나(149) — 옛 자료의 둘째 줄부터는 손대지 않고 그대로 둠 const first = rowField(route?.rows[0] ?? { item: "", dest: "start", note: "" }, kind); - const old = editBase ? data.base : route; + const old = base ?? route; const place = placePicker( projectId, { address: old?.address ?? "", lon: old?.lon ?? null, lat: old?.lat ?? null }, @@ -163,11 +159,24 @@ export function openPlaceModal( const where = place.value(); const fresh = liveMilestone(projectId); let segment: string | undefined; // [등록] · 소재지 옮김 뒤 그 구간 길찾기 한 번 - if (kind === "base") { - Object.assign(fresh.base, where, { enabled: true, name: text, dir: null }); - if (!fresh.base.legs.length) fresh.base.legs = [{ road: null, km: null }]; + if (kind === "base" && base) { + const b = fresh.bases.find((x) => x.id === base.id); + if (!b) return close(); + Object.assign(b, where, { name: text, dir: null }); + putMilestone(projectId, fresh); + if (moved(where)) segment = b.id; + } else if (kind === "base") { + // 254 새 기준점 = 상자만 · 선은 연결 점 ● 로 이음(이을 때 길찾기) + fresh.bases.push({ + id: `b${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`, + name: text, + dir: null, + legs: [{ road: null, km: null }], + to: "site", + linked: false, + ...where, + }); putMilestone(projectId, fresh); - if (moved(where)) segment = "base"; } else if (route) { const r = fresh.routes.find((x) => x.id === route.id); if (!r) return close(); @@ -180,9 +189,9 @@ export function openPlaceModal( } else { if (!text) return name.setError("거래처 이름을 넣으세요."); // 155 등록 = 상자만 · 선은 다이어그램 연결 점 ● 로 이음(이을 때 길찾기) - const id = addRoute?.({ + addRoute?.({ name: text, - via_base: true, + to: "site", linked: false, dir: null, rows: rowValues(), @@ -198,12 +207,17 @@ export function openPlaceModal( label: "삭제", variant: "danger", onClick: async () => { - const label = editBase ? data.base.name || "기준점" : route?.name || "거래처"; + const label = base ? base.name || "기준점" : route?.name || "거래처"; if (!(await showConfirmDialog(`「${label}」 항목을 지울까요? — [저장] 때 반영`, "삭제"))) return; const fresh = liveMilestone(projectId); - if (editBase) fresh.base = { enabled: false, name: "", address: "", dir: null, legs: [] }; - else fresh.routes = fresh.routes.filter((r) => r.id !== route!.id); // 경로 탭도 같이 + if (base) { + fresh.bases = fresh.bases.filter((b) => b.id !== base.id); + // 254 그 기준점에 이은 상자 = 안 이음(옛 구간 km = 다른 길 · 연결 점으로 다시 이음) + for (const o of [...fresh.bases, ...fresh.routes]) + if (o.to === base.id) + Object.assign(o, { to: "site", linked: false, legs: [{ road: null, km: null }] }); + } else fresh.routes = fresh.routes.filter((r) => r.id !== route!.id); // 경로 탭도 같이 putMilestone(projectId, fresh); close(); }, diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index d4b9c85b6..b3acd5fdd 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -14,6 +14,7 @@ import { } from "@ui/ui_template_context_menu"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { chainOf, ownerOf } from "./B08_Quantity_Milestone_Layout"; import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; import { @@ -131,28 +132,26 @@ export function routeView(projectId: string, routeId: string): MilestoneView { if (!r) return out; add(r.id, r.name || "거래처", r.lon, r.lat); if (r.linked === false) return out; - if (d.base.enabled && r.via_base) add("base", d.base.name || "기준점", d.base.lon, d.base.lat); + for (const b of chainOf(d, r.to)) add(b.id, b.name || "기준점", b.lon, b.lat); // 254 기준점 사슬 add("start", "시점", start()?.[0], start()?.[1]); return out; }; const lines = (): Array> => { const d = liveMilestone(projectId); const r = d.routes.find((x) => x.id === routeId); - const via = !!r && r.linked !== false && d.base.enabled && r.via_base; - return [segmentPath(routeId), ...(via ? [segmentPath("base")] : [])].filter( - (p) => p.length >= 2, - ); + const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : []; + return [routeId, ...chain].map(segmentPath).filter((p) => p.length >= 2); }; const map = editMap(projectId, { hint: "", onMove: (key, lon, lat) => void reverseAddress(projectId, lon, lat).then((addr) => { const fresh = liveMilestone(projectId); - const target = key === "base" ? fresh.base : fresh.routes.find((x) => x.id === key); + const target = ownerOf(fresh, key); if (!target) return; Object.assign(target, { lon, lat, address: addr }); putMilestone(projectId, fresh); - void findSegmentKm(projectId, key === "base" ? "base" : routeId).then(() => drawMap()); + void findSegmentKm(projectId, key).then(() => drawMap()); }), }); const drawMap = (): void => map.set(pins(), lines()); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index 456a44b57..4f97cc489 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -11,16 +11,22 @@ * 경로 좌표(244-9) = 마지막 [길찾기] 응답을 메모리에만(카카오 지도 길 선 · 새로 고침이면 사라짐) * 경로 그림 자동(244-11) = [길찾기] 뒤 길 선 · 표지를 서버 POST …/image/auto → 서버 헤드리스 캡처 그림 * (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김 - * 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 → 기준점(또는 시점) · 기준점 → 시점) · + * 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 · 기준점 → to(기준점 · 시점) · 254) · * 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점) + * 기준점 여럿(254) = `bases[]` · 길 선 표 키 = 거래처 · 기준점 id · 옛 꼴 캐시 = 읽을 때 새 꼴(`migrateMilestone`) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; -import { PAVED, type MilestoneData, type SegKind } from "./B08_Quantity_Milestone_Calc"; -import { mapMarks, putAutoKm, segmentEnds } from "./B08_Quantity_Milestone_Layout"; +import { + PAVED, + migrateMilestone, + type MilestoneData, + type SegKind, +} from "./B08_Quantity_Milestone_Calc"; +import { mapMarks, ownerOf, putAutoKm, segmentEnds } from "./B08_Quantity_Milestone_Layout"; import type { SiteAddress } from "./B08_Quantity_UI_Milestone_Modal"; export interface SiteInfo { @@ -39,7 +45,7 @@ interface Payload { export const EMPTY: MilestoneData = { site: { mid_station_m: null, mid_road: "비포장" }, - base: { enabled: false, name: "", address: "", dir: null, legs: [] }, + bases: [], routes: [], }; @@ -97,9 +103,11 @@ export const routeImageKey = (routeId: string): string | null => { export const hasMilestoneDraft = (projectId: string): boolean => readState("b08-milestone", projectId) !== null; -/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본) */ +/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본 · 옛 꼴 캐시 = 새 꼴 · 254) */ export function liveMilestone(projectId: string): MilestoneData { - return clone(readState("b08-milestone", projectId) ?? saved?.data ?? EMPTY); + return migrateMilestone( + clone(readState("b08-milestone", projectId) ?? saved?.data ?? EMPTY), + ); } /** 고친 한 벌을 캐시에 · 화면에 알림 */ @@ -214,7 +222,7 @@ export function roadOptions(kind: SegKind): Array<{ value: string; text: string return [PAVED[kind], "비포장"].map((r) => ({ value: r, text: r })); } -/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 id · "base") → [위도, 경도] 목록(메모리만 · 저장 안 함) */ +/** 마지막 [길찾기] 길 선 — 구간(from = 거래처 · 기준점 id) → [위도, 경도] 목록(메모리만 · 저장 안 함) */ const paths = new Map>(); export const segmentPath = (from: string): Array<[number, number]> => paths.get(from) ?? []; /** 경로 탭 · 자동 그림 길 선 = 거래처 쪽 구간 */ @@ -229,7 +237,7 @@ export async function findSegmentKm( image = true, ): Promise { const data = liveMilestone(projectId); - const owner = from === "base" ? data.base : data.routes.find((r) => r.id === from); + const owner = ownerOf(data, from); if (owner?.manual) return ""; // 155 수기 입력 = 카카오 길찾기 안 함 const seg = segmentEnds(data, from, siteInfo()?.start_lonlat ?? null); if (!seg) return ""; @@ -253,9 +261,8 @@ export async function findSegmentKm( if ((res.path?.length ?? 0) >= 2) paths.set(from, res.path!); else paths.delete(from); const fresh = liveMilestone(projectId); // 기다리는 동안 고친 값 위에 - const legs = from === "base" ? fresh.base.legs : fresh.routes.find((r) => r.id === from)?.legs; - if (legs?.[0]?.auto_km !== res.km) { - putAutoKm(fresh, from, seg.key, res.km); + if (ownerOf(fresh, from)?.legs[0]?.auto_km !== res.km) { + putAutoKm(fresh, from, res.km); putMilestone(projectId, fresh); } else changes.dispatchEvent(new Event("change")); if (image && seg.key === "route" && paths.has(from)) diff --git a/resources/tester/test_244_12_b08_milestone_base_view.py b/resources/tester/test_244_12_b08_milestone_base_view.py index 2c83b6a93..f08f32187 100644 --- a/resources/tester/test_244_12_b08_milestone_base_view.py +++ b/resources/tester/test_244_12_b08_milestone_base_view.py @@ -16,6 +16,7 @@ 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 migrate_milestone # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) LONG = "경상남도 거창군 북상면 병곡리 968-31" @@ -58,6 +59,7 @@ def _clipped(note, box_w): def _lay(data, site=None): + data = migrate_milestone(data) # 254 옛 꼴 → bases · to(옛 기준점 = b1) return _node(f"layoutMilestone({json.dumps(data, ensure_ascii=False)}, {json.dumps(site)}, 1)") diff --git a/resources/tester/test_244_13_b08_milestone_place_leg.py b/resources/tester/test_244_13_b08_milestone_place_leg.py index 956d4631d..e462cbf04 100644 --- a/resources/tester/test_244_13_b08_milestone_place_leg.py +++ b/resources/tester/test_244_13_b08_milestone_place_leg.py @@ -21,7 +21,10 @@ 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 +from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402 + compute_milestone, + migrate_milestone, +) IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) B08 = "B08_Quantity/B08_Quantity_" @@ -90,29 +93,29 @@ def test_포장_도로_구분_거울(): def test_도로_구분_갈래_연결선_구간_끝(): - d = json.dumps(WITH_BASE) - off = json.dumps({**WITH_BASE, "base": {**WITH_BASE["base"], "enabled": False}}) + # 254 옛 꼴 → bases · to(옛 기준점 = b1) + d = json.dumps(migrate_milestone(WITH_BASE)) + off = {**WITH_BASE, "base": {**WITH_BASE["base"], "enabled": False}} + off = json.dumps(migrate_milestone(off)) got = _node( [CALC, LAYOUT], f"""const D = {d}; const O = {off}; const S = [127.83, 35.64]; -console.log(JSON.stringify([["g", "d", "base", "start"].map((f) => PAVED[segKind(D, f)]), - ["g", "base"].map((f) => segKind(O, f)), - ["g", "d", "base", "start"].map((f) => chainVia(D, f)), ["g", "base"].map((f) => chainVia(O, f)), - segmentEnds(D, "g", S), segmentEnds(D, "d", S), segmentEnds(D, "base", S), - segmentEnds(D, "start", S), segmentEnds(O, "base", S), segmentEnds(O, "g", S), +console.log(JSON.stringify([["g", "d", "b1", "start"].map((f) => PAVED[segKind(D, f)]), + ["g", "b1"].map((f) => segKind(O, f)), + segmentEnds(D, "g", S), segmentEnds(D, "d", S), segmentEnds(D, "b1", S), + segmentEnds(D, "start", S), segmentEnds(O, "b1", S), segmentEnds(O, "g", S), kmSegments(D, "g", S).map((s) => s.key), kmSegments(D, "d", S).map((s) => s.key)]));""", ) # 152 포장 종류 = 선 자리 — 기준점 거침 거래처 2차 · 안 거침 포장 · 기준점 → 시점 1차 · 시점 → 중점 포장 - assert got[0] == ["2차포장", "포장", "1차포장", "포장"] and got[1] == ["direct", "base"] - assert got[2] == [True, False, True, False] and got[3] == [False, False] - assert got[4] == {"key": "route", "text": "g → 병곡리", "from": [127.9, 35.68], + assert got[0] == ["2차포장", "포장", "1차포장", "포장"] and got[1] == ["direct", "direct"] + assert got[2] == {"key": "route", "text": "g → 병곡리", "from": [127.9, 35.68], "to": [127.85, 35.66]} # fmt: skip - assert got[5]["text"] == "d → 시점" and got[5]["to"] == [127.83, 35.64] - assert got[6] == {"key": "base", "text": "병곡리 → 시점", "from": [127.85, 35.66], + assert got[3]["text"] == "d → 시점" and got[3]["to"] == [127.83, 35.64] + assert got[4] == {"key": "base", "text": "병곡리 → 시점", "from": [127.85, 35.66], "to": [127.83, 35.64]} # fmt: skip - assert got[7] is None and got[8] is None # 시점 → 중점 = 손 입력 · 기준점 없음 - assert got[9]["text"] == "g → 시점" # 기준점 꺼짐 = 시점으로 바로 - assert got[10] == ["route", "base"] and got[11] == ["route"] + assert got[5] is None and got[6] is None # 시점 → 중점 = 손 입력 · 기준점 없음 + assert got[7]["text"] == "g → 시점" # 기준점 꺼짐 = 시점으로 바로 + assert got[8] == ["route", "base"] and got[9] == ["route"] def test_표_열_기준점_따라(): @@ -237,13 +240,13 @@ await loadMilestone("p1"); const a = await findSegmentKm("p1", "g"); await new Promise((r) => setTimeout(r, 10)); const afterRoute = CALLS.length; -const b = await findSegmentKm("p1", "base"); +const b = await findSegmentKm("p1", "b1"); // 254 옛 꼴 캐시 → 기준점 id b1 const writes = WRITES.n; -await findSegmentKm("p1", "base"); // 같은 km = 캐시 안 씀 +await findSegmentKm("p1", "b1"); // 같은 km = 캐시 안 씀 const live = liveMilestone("p1"); console.log(JSON.stringify({{ a, b, calls: CALLS.slice(1), afterRoute, writes, again: WRITES.n, - route: live.routes[0].legs, base: live.base.legs, path: segmentPath("g"), - routeLines: routePaths("g"), baseLines: routePaths("base"), + route: live.routes[0].legs, base: live.bases[0].legs, path: segmentPath("g"), + routeLines: routePaths("g"), baseLines: routePaths("b1"), start: await findSegmentKm("p1", "start") }}));""" got = _node([CALC, LAYOUT, STATE], script, STATE_STUBS) assert got["a"] == "g → 병곡리 10.5 km" and got["b"] == "병곡리 → 시점 10.5 km" @@ -290,7 +293,7 @@ def test_화면_등록_모달_연결선_모달(): assert "editMap(projectId" in place and "onClick: (lon, lat) => void moveTo(lon, lat)" in place assert "/quantity/milestone/reverse?lon=" in place # ② 상자 누름 = 같은 등록 모달 · [기준점 +] 없음 - assert 'base: () => openPlaceModal(projectId, { add: false, id: "base" }),' in diagram + assert "base: (id) => openPlaceModal(projectId, { add: false, id })," in diagram assert 'label: "기준점 +"' not in diagram and "addBase" not in diagram # ③ 연결선 누름 = 연결선 모달 · 시점 → 중점 = 손 입력만 assert "openLegInfo(projectId, ln.from);" in diagram and "b08-milestone__line-hit" in diagram diff --git a/resources/tester/test_244_2_b08_milestone_diagram.py b/resources/tester/test_244_2_b08_milestone_diagram.py index 203e3c163..37577bf9e 100644 --- a/resources/tester/test_244_2_b08_milestone_diagram.py +++ b/resources/tester/test_244_2_b08_milestone_diagram.py @@ -14,10 +14,13 @@ ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) +from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 + IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) def _layout(data, site=None, mid_km=0.75): + data = migrate_milestone(data) # 254 옛 꼴(base · via_base) → bases · to(옛 기준점 = b1) src = (ROOT / "B08_Quantity" / "B08_Quantity_Milestone_Layout.ts").read_text(encoding="utf-8") body = ( f"\nconst d = {json.dumps(data, ensure_ascii=False)};" @@ -95,17 +98,17 @@ def test_기준점_기준_방위_선_글(): ), # fmt: skip ) cx, cy = _center(lay, "site") - bx, by = _center(lay, "base") + bx, by = _center(lay, "b1") assert bx < cx - 200 and abs(by - cy) < 1 # 기준점 = 공사지 서쪽 gx, gy = _center(lay, "g") assert abs(gx - bx) < 1 and gy < by - 200 # 거창군청 = 기준점 북쪽 kx, _ = _center(lay, "k") assert kx > cx + 200 # 기준점 안 거침 = 공사지 동쪽 labels = {(ln["from"], ln["to"]): ln["labels"] for ln in lay["lines"]} - assert labels[("g", "base")] == ["2차포장 23.8km"] - assert labels[("base", "start")] == ["1차포장 0.6km", "비포장 0.8km"] + assert labels[("g", "b1")] == ["2차포장 23.8km"] + assert labels[("b1", "start")] == ["1차포장 0.6km", "비포장 0.8km"] assert labels[("k", "start")] == [] - assert next(b for b in lay["boxes"] if b["id"] == "base")["label"] == "병곡리 968-31" + assert next(b for b in lay["boxes"] if b["id"] == "b1")["label"] == "병곡리 968-31" def test_경위도_있으면_방위각이_8방보다_앞섬(): diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 613e5d68d..c29c60cf8 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -18,6 +18,7 @@ if str(ROOT) not in sys.path: from B01_Dashboard import B01_Dashboard_Map as vmap # noqa: E402 from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) @@ -58,8 +59,9 @@ def test_방위_좌표_자동(): # 기준점 좌표 없음 → 시점 좌표 기준 · 업체 = 시점 남쪽 · 기준점 안 거침 업체 = 동쪽 "routes": [_route("s", 127.8, 35.6), _route("e", 127.9, 35.7, via=False)], } - lay = _node(f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)") - bx, by = _center(lay, "base") + # 254 옛 꼴 → bases · to(옛 기준점 = b1) + lay = _node(f"layoutMilestone({json.dumps(migrate_milestone(data))}, {json.dumps(site)}, 0)") + bx, by = _center(lay, "b1") sx, sy = _center(lay, "s") assert abs(sx - bx) < 1 and sy > by + 200 # 남 = 아래 cx, cy = _center(lay, "site") @@ -68,8 +70,9 @@ def test_방위_좌표_자동(): # 기준점 좌표 있음 → 기준점 기준(업체가 기준점 북쪽) data["base"] = {**base, "lon": 127.7, "lat": 35.7} data["routes"] = [_route("n", 127.7, 35.8)] - lay = _node(f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)") - bx, by = _center(lay, "base") + # 254 옛 꼴 → bases · to(옛 기준점 = b1) + lay = _node(f"layoutMilestone({json.dumps(migrate_milestone(data))}, {json.dumps(site)}, 0)") + bx, by = _center(lay, "b1") nx, ny = _center(lay, "n") assert bx < cx - 200 # 기준점 = 시점 서쪽(좌표) assert abs(nx - bx) < 1 and ny < by - 200 @@ -188,17 +191,17 @@ def test_선_글_바로_고침_모달과_한_벌(): "legs": [{"road": "1차포장", "km": 0.6}]}, "routes": [{**_route("g"), "legs": [{"road": "2차포장", "km": None}]}], } # fmt: skip - d = json.dumps(data) + d = json.dumps(migrate_milestone(data)) # 254 옛 꼴 → bases · to(옛 기준점 = b1) got = _node( f"[legAt({d}, 'g', 0, 0.75), legAt({d}, 'start', 0, 0.75), legAt({d}, 'x', 0, 0)," f" setLeg(setLeg(setLeg({d}, 'g', 0, {{road: '1차포장', km: 23.8}})," - f" 'base', 0, {{km: null}}), 'start', 0, {{road: '2차포장', km: 1.2345}})]" + f" 'b1', 0, {{km: null}}), 'start', 0, {{road: '2차포장', km: 1.2345}})]" ) assert got[0] == {"road": "2차포장", "km": None} assert got[1] == {"road": "비포장", "km": 0.75} and got[2] is None after = got[3] assert after["routes"][0]["legs"] == [{"road": "1차포장", "km": 23.8}] - assert after["base"]["legs"] == [{"road": "1차포장", "km": None}] + assert after["bases"][0]["legs"] == [{"road": "1차포장", "km": None}] assert after["site"] == {"mid_station_m": 1234.5, "mid_road": "2차포장"} # 표 셈 — 포장 종류 = 선 자리(152 · 기준점 거침 거래처 = 2차포장 · 저장 값 1차포장 이어도) (row,) = compute_milestone(after, 1500.0) diff --git a/resources/tester/test_244_5_b08_milestone_base_dir.py b/resources/tester/test_244_5_b08_milestone_base_dir.py index 0b1b161e1..216b0aa41 100644 --- a/resources/tester/test_244_5_b08_milestone_base_dir.py +++ b/resources/tester/test_244_5_b08_milestone_base_dir.py @@ -5,9 +5,15 @@ import json import re import subprocess +import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 + IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) @@ -17,6 +23,7 @@ def _read(rel): def _layout(base, site): data = {"site": {"mid_station_m": None, "mid_road": "비포장"}, "base": base, "routes": []} + data = migrate_milestone(data) # 254 옛 꼴 → bases(옛 기준점 = b1) src = IMPORTS.sub("", _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")) expr = f"layoutMilestone({json.dumps(data)}, {json.dumps(site)}, 0)" result = subprocess.run( @@ -27,7 +34,7 @@ def _layout(base, site): assert result.returncode == 0, result.stderr lay = json.loads(result.stdout) center = {b["id"]: (b["x"] + b["w"] / 2, b["y"] + b["h"] / 2) for b in lay["boxes"]} - return center["site"], center["base"] + return center["site"], center["b1"] BASE = {"enabled": True, "name": "기준", "address": "", "legs": []} @@ -60,6 +67,5 @@ def test_좌측_패널_방위_칸_없음_모달_안내(): ] assert "방위는 이 자리로 자동" in base # 244-13 — 기준점 = 등록 모달 분류 기준위치 layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") - assert ( - "placeDeg({ dir: null, lon: data.base.lon, lat: data.base.lat }, startLL) ?? 270" in layout - ) + assert "placeDeg({ dir: null, lon: b.lon, lat: b.lat }, anchorLL) ??" in layout + assert '(anchor === "site" && !used.has(keyOf("site", 270)) ? 270 : free(anchor))' in layout diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py index 9e609a8ef..ce9f8815e 100644 --- a/resources/tester/test_244_6_b08_milestone_pan_client.py +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -82,13 +82,13 @@ def test_실제_포인터_사건으로_팬(): def test_거래처_상자_항목_글(): data = { "site": {"mid_station_m": None, "mid_road": "비포장"}, - "base": {"enabled": False, "name": "", "address": "", "dir": None, "legs": []}, + "bases": [], "routes": [ - {"id": "g", "name": "거창군청", "address": "", "via_base": True, "dir": None, + {"id": "g", "name": "거창군청", "address": "", "to": "site", "dir": None, "legs": [], "rows": [{"item": "장비", "dest": "start", "note": ""}, {"item": " 장비 ", "dest": "mid", "note": ""}, {"item": "골재", "dest": "mid", "note": ""}]}, - {"id": "k", "name": "", "address": "", "via_base": True, "dir": None, + {"id": "k", "name": "", "address": "", "to": "site", "dir": None, "legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]}, ], } # fmt: skip @@ -118,12 +118,12 @@ def test_표_폭_다이어그램_폭_좌측_카드_없음(): site = modal[modal.index("export function openSiteInfo") :] assert 'label: "중점 측점 (m)"' in site and 'label: "시점 → 중점 도로"' in site assert "fresh.site.mid_station_m =" in site and "putMilestone(projectId, fresh)" in site - # 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 삭제 = 기준점 없음 + # 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 254 여럿 · 삭제 = 그 기준점만 base = modal[ modal.index("export function openPlaceModal") : modal.index("interface SiteAddress") ] - assert "enabled: true," in base and 'label: "삭제"' in base - assert "fresh.base = { enabled: false," in base + assert "fresh.bases.push({" in base and 'label: "삭제"' in base + assert "fresh.bases = fresh.bases.filter((b) => b.id !== base.id);" in base assert 'label: "기준점 +"' not in ui and "openBaseInfo" not in ui and "addBase" not in ui assert "content: panContent(vw, vh)" in ui and "panStart," in ui diff --git a/resources/tester/test_244_7_b08_milestone_kakao.py b/resources/tester/test_244_7_b08_milestone_kakao.py index 30f715237..4f99399f2 100644 --- a/resources/tester/test_244_7_b08_milestone_kakao.py +++ b/resources/tester/test_244_7_b08_milestone_kakao.py @@ -24,7 +24,10 @@ 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 +from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402 + compute_milestone, + migrate_milestone, +) IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) FAKE_KEY = "fake-kakao-key-0123456789" @@ -144,7 +147,8 @@ def test_키_값_응답_로그에_안_나옴(monkeypatch, caplog): def _data(base_enabled=True, base_ll=(127.85, 35.66)): - return { + """254 옛 꼴로 적고 새 꼴(bases · to · 옛 기준점 = b1)로 옮겨 씀.""" + return migrate_milestone({ "site": {"mid_station_m": None, "mid_road": "비포장"}, "base": {"enabled": base_enabled, "name": "병곡리 968-31", "address": "", "dir": None, "lon": base_ll[0], "lat": base_ll[1], "legs": []}, @@ -156,7 +160,7 @@ def _data(base_enabled=True, base_ll=(127.85, 35.66)): "lon": None, "lat": None, "legs": [{"road": None, "km": None}], "rows": [{"item": "철근", "dest": "start", "note": ""}]}, ], - } # fmt: skip + }) # fmt: skip LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts" @@ -179,27 +183,27 @@ def test_구간_채움(): two = _node(LAYOUT, f"kmSegments({d}, 'r2', null)") assert two == [{"key": "route", "text": "김천역 → 시점", "from": None, "to": None}] # 자동 km 넣기 — 빈 구간 = 안 고른 줄 하나 · 사용자 km 는 그대로 - put = _node(LAYOUT, f"putAutoKm(putAutoKm({d}, 'r1', 'route', 10.5), 'r1', 'base', 1.4)") + put = _node(LAYOUT, f"putAutoKm(putAutoKm({d}, 'r1', 10.5), 'b1', 1.4)") assert put["routes"][0]["legs"] == [{"road": "2차포장", "km": 23.8, "auto_km": 10.5}] - assert put["base"]["legs"] == [{"road": None, "km": None, "auto_km": 1.4}] + assert put["bases"][0]["legs"] == [{"road": None, "km": None, "auto_km": 1.4}] # 선 글 = 사용자 값 우선 · 다르면 호버 · 안 고름 = 「도로 구분 고르기」 + 자동 값 lay = _node(LAYOUT, f"layoutMilestone({json.dumps(put)}, {{start_lonlat: {START}}}, 0.75)") line = {ln["from"]: ln for ln in lay["lines"]} assert line["r1"]["labels"] == ["2차포장 23.8km"] and line["r1"]["tips"] == ["자동 값 10.5 km"] - assert line["base"]["labels"] == ["도로 구분 고르기 1.4km"] and line["base"]["tips"] == [""] + assert line["b1"]["labels"] == ["도로 구분 고르기 1.4km"] and line["b1"]["tips"] == [""] assert line["start"]["labels"] == ["비포장 0.75km"] # 시점 → 중점 = 노선 측점 거리 그대로 def test_사용자_값_우선_고르기_표시(): data = _data() data["routes"][0]["legs"] = [{"road": "2차포장", "km": None, "auto_km": 10.5}] - data["base"]["legs"] = [{"road": "1차포장", "km": 0.6, "auto_km": 1.4}] + data["bases"][0]["legs"] = [{"road": "1차포장", "km": 0.6, "auto_km": 1.4}] a, b = compute_milestone(data, 1500.0) # 거래처 = 자동 10.5 · 기준점 = 사용자 0.6(자동 1.4 무시) · 중점 1.5 비포장 assert (a["s2"], a["s1"], a["un"], a["total"], a["pick"]) == (10.5, 0.6, 1.5, 12.6, False) # 152 도로 구분 안 고름 = 선 자리 포장 종류로 셈(pick 늘 거짓) — 빈 km = 0 assert (b["total"], b["pick"]) == (0, False) - data["base"]["legs"][0]["road"] = None # 기준점 → 시점 = 1차포장(자리) + data["bases"][0]["legs"][0]["road"] = None # 기준점 → 시점 = 1차포장(자리) a2, _ = compute_milestone(data, 1500.0) assert (a2["s1"], a2["total"], a2["pick"]) == (0.6, 12.6, False) # TS 짝 거울 @@ -210,7 +214,7 @@ def test_사용자_값_우선_고르기_표시(): # 저장 꼴 — auto_km · road null 받음 · 빠진 road = 옛 기본 2차포장 saved = ms.Milestone.model_validate(data).model_dump() assert saved["routes"][0]["legs"][0]["auto_km"] == 10.5 - assert saved["base"]["legs"][0]["road"] is None + assert saved["bases"][0]["legs"][0]["road"] is None assert ms.Leg.model_validate({"km": 1}).road == "2차포장" diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index 8f0393f93..7fd521f39 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -19,6 +19,7 @@ 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 migrate_milestone # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) BASE = "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone" @@ -118,14 +119,14 @@ def test_표지_셋(): "routes": [{"id": "r1", "name": "거창군청", "lon": 127.9, "lat": 35.68, "via_base": True}, {"id": "r2", "name": "", "lon": None, "lat": None, "via_base": False}], } # fmt: skip - d = json.dumps(data) + d = json.dumps(migrate_milestone(data)) # 254 옛 꼴 → bases · to(옛 기준점 = b1) got = _node( [LAYOUT], f"""const D = {d}; console.log(JSON.stringify([ mapMarks(D, "r1", [127.83, 35.64]), mapMarks(D, "r2", [127.83, 35.64]), - mapMarks({{ ...D, base: {{ ...D.base, enabled: false }} }}, "r1", null), + mapMarks({{ ...D, bases: [] }}, "r1", null), ]));""", ) assert got[0] == [ diff --git a/resources/tester/test_250_1_b08_milestone_roads_width.py b/resources/tester/test_250_1_b08_milestone_roads_width.py index d444804b8..93982a914 100644 --- a/resources/tester/test_250_1_b08_milestone_roads_width.py +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -9,7 +9,11 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) -from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone, road_of # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402 + compute_milestone, + migrate_milestone, + road_of, +) IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) @@ -87,7 +91,9 @@ def test_153_지도_보기_유지_상자_색_끌어_옮김(): assert var in block[: block.index("}")], kind ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") assert 'if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift);' in ui - assert 'label: "방위 배치"' in ui and "fresh.routes.forEach((r) => (r.pos = null));" in ui # 159 이름 + assert ( + 'label: "방위 배치"' in ui and "fresh.routes.forEach((r) => (r.pos = null));" in ui + ) # 159 이름 def test_153_끌어_놓은_자리_레이아웃(): @@ -96,23 +102,24 @@ def test_153_끌어_놓은_자리_레이아웃(): "base": {"enabled": True, "name": "기준", "address": "", "dir": "W", "legs": [], "pos": [500, -40]}, "routes": [{"id": "r", "name": "a", "address": "", "via_base": False, "dir": "N", "legs": [], "rows": [], "pos": [-700, 300]}]} # fmt: skip - expr = f"layoutMilestone({json.dumps(data)}, {{}}, 0.5).boxes.filter((b) => b.id === 'base' || b.id === 'r').map((b) => [b.id, b.x, b.y])" + data = migrate_milestone(data) # 254 옛 꼴 → bases(옛 기준점 = b1) + expr = f"layoutMilestone({json.dumps(data)}, {{}}, 0.5).boxes.filter((b) => b.id === 'b1' || b.id === 'r').map((b) => [b.id, b.x, b.y])" out = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", src + f"\nconsole.log(JSON.stringify({expr}));"], capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert out.returncode == 0, out.stderr - assert sorted(json.loads(out.stdout)) == [["base", 500, -40], ["r", -700, 300]] + assert sorted(json.loads(out.stdout)) == [["b1", 500, -40], ["r", -700, 300]] def test_154_연결_점_끌어_잇기(): ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") assert 'if (box.kind === "route") g.append(connectDot(box, shift));' in ui - assert 'const via = t === "base";' in ui and "route.via_base = via;" in ui + assert "route.to = t;" in ui # 254 놓은 기준점 id · 공사지 = "site" assert "route.legs = [{ road: null, km: null }];" in ui # 옛 구간 km 비움 assert "void findSegmentKm(projectId, route.id).then(toastKm);" in ui # 새 길찾기 한 번 - assert "if (via === now) return;" in ui # 같은 연결 = 그대로 + assert "if (route.linked !== false && route.to === t) return;" in ui # 같은 연결 = 그대로 modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") assert ( 'createCheckbox("기준점 거침")' not in modal and "via_base: via.input.checked" not in modal @@ -145,7 +152,7 @@ def test_155_요소_추가_안_이은_거래처_수기_입력(): state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") assert "if (owner?.manual) return" in state # 수기 입력 = 카카오 길찾기 안 함 layout = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") - assert "if (!route || route.linked === false) return null;" in layout + assert "if (!owner || owner.linked === false) return null;" in layout # 254 거래처 · 기준점 def test_157_도구_왼쪽_아래_경로_탭_지도_그림_변경_초기화(): diff --git a/resources/tester/test_254_1_b08_milestone_bases.py b/resources/tester/test_254_1_b08_milestone_bases.py new file mode 100644 index 000000000..5cccd62e7 --- /dev/null +++ b/resources/tester/test_254_1_b08_milestone_bases.py @@ -0,0 +1,213 @@ +"""254-1 B08 거리이정표 여러 기준점(사용자 162-10) — `bases[]` · 경로 `to` · 옛 꼴 옮김 · +기준점 사슬 셈(파이썬 · TS 거울) · 돌기 막음 · +도로 구분(시점에 닿는 기준점 구간 = 1차 · 그 밖 = 2차). + +견본 사슬 = 거창군청 → 기준점 A(병곡리) → 기준점 B(신원) → 시점 · 김천역 → B → 시점 · +철근 상회 → 시점(직접). +""" + +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_Haul import with_haul_routes # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import ( # noqa: E402 + base_chain, + compute_milestone, + migrate_milestone, +) + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +B08 = "B08_Quantity/B08_Quantity_" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(files, expr): + src = "\n".join(IMPORTS.sub("", _read(f)) for f in files) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _leg(road, km): + return {"road": road, "km": km} + + +def _base(bid, to, legs, linked=True): + return {"id": bid, "name": bid, "address": "", "dir": None, "legs": legs, "to": to, + "linked": linked} # fmt: skip + + +def _route(rid, to, legs, dest="start"): + return {"id": rid, "name": rid, "address": "", "dir": None, "to": to, "legs": legs, + "rows": [{"item": "장비", "dest": dest, "note": ""}]} # fmt: skip + + +CHAIN = { + "site": {"mid_station_m": 800, "mid_road": "비포장"}, + "bases": [ + _base("A", "B", [_leg(None, 4.5), _leg("비포장", 0.3)]), + _base("B", "site", [_leg(None, 0.6)]), + ], + "routes": [ + _route("g", "A", [_leg(None, 23.8)]), + _route("k", "B", [_leg(None, 61.2)], dest="mid"), + _route("c", "site", [_leg(None, 2.0)]), + ], +} + +OLD = { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "병곡리", "address": "주소", "dir": "E", "lon": 127.85, + "lat": 35.66, "pos": [10, 20], "legs": [_leg("1차포장", 0.6)]}, + "routes": [ + {"id": "r1", "name": "거창군청", "address": "", "via_base": True, "dir": None, + "legs": [_leg("2차포장", 23.8)], "rows": [{"item": "장비", "dest": "start", "note": ""}]}, + {"id": "r2", "name": "김천역", "address": "", "via_base": False, "dir": None, + "legs": [_leg(None, 5)], "rows": [{"item": "철근", "dest": "start", "note": ""}]}, + ], +} # fmt: skip + + +def test_옛_꼴_옮김_거울(): + new = migrate_milestone(OLD) + assert "base" not in new and [b["id"] for b in new["bases"]] == ["b1"] + b1 = new["bases"][0] + assert (b1["to"], b1["linked"], b1["name"], b1["pos"]) == ("site", True, "병곡리", [10, 20]) + assert b1["legs"] == [_leg("1차포장", 0.6)] and b1["lon"] == 127.85 + assert [(r["id"], r["to"], "via_base" in r) for r in new["routes"]] == [ + ("r1", "b1", False), + ("r2", "site", False), + ] + # 꺼진 기준점 = bases 없음 · 거침 경로도 시점으로 + off = migrate_milestone({**OLD, "base": {**OLD["base"], "enabled": False}}) + assert off["bases"] == [] and [r["to"] for r in off["routes"]] == ["site", "site"] + assert migrate_milestone(new) == new # 새 꼴 = 그대로 + # TS 짝 — 옮긴 꼴 · 표 합이 같음 + calc = [B08 + "Milestone_Calc.ts"] + ts = _node(calc, f"migrateMilestone({json.dumps(OLD)})") + assert ts["bases"][0]["id"] == "b1" and [r["to"] for r in ts["routes"]] == ["b1", "site"] + assert ts["bases"][0]["legs"] == b1["legs"] and ts["bases"][0]["pos"] == [10, 20] + assert _node(calc, f"computeMilestone({json.dumps(OLD)}, 1500)") == compute_milestone( + OLD, 1500.0 + ) + # 옛 표 합 그대로 — 2차 23.8 + 1차 0.6 + (a, b) = compute_milestone(OLD, 1500.0) + assert (a["s2"], a["s1"], a["total"]) == (23.8, 0.6, 24.4) and (b["pv"], b["total"]) == (5, 5) + + +def test_서버_읽기_받기_새_꼴(tmp_path, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + # 옛 파일 읽기 = 새 꼴(지우지 않음 · 저장 때 새 꼴) + folder = tmp_path / "B08_Quantity" / "milestone" + folder.mkdir(parents=True) + (folder / "milestone.json").write_text(json.dumps(OLD, ensure_ascii=False), "utf-8") + data = ms.read_data(tmp_path) + assert [b["id"] for b in data["bases"]] == ["b1"] and data["routes"][0]["to"] == "b1" + assert "base" not in data and "via_base" not in data["routes"][0] + + async def root(_pid): + return tmp_path + + monkeypatch.setattr(ms, "_project_root", root) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + url = f"/api/projects/{uuid4()}/quantity/milestone" + saved = client.put(url, json=OLD).json() # 옛 꼴 받기 = 새 꼴로 저장 + stored = json.loads((folder / "milestone.json").read_text("utf-8")) + assert "base" not in stored and stored["bases"][0]["id"] == "b1" + assert stored["routes"][0]["to"] == "b1" and "via_base" not in stored["routes"][0] + assert saved["table"] == compute_milestone(OLD, None) + # 새 꼴 사슬 왕복 · 겹친 id · 공사지 상자 id = 거절 + assert client.put(url, json=CHAIN).json()["data"]["bases"][0]["to"] == "B" + twice = {**CHAIN, "bases": [*CHAIN["bases"], _base("g", "site", [])]} + assert client.put(url, json=twice).status_code == 422 + box = {**CHAIN, "bases": [_base("site", "site", [])]} + assert client.put(url, json=box).status_code == 422 + # 운반 자동 상자 = 시점으로(안 이음) + (auto,) = with_haul_routes({"routes": []}, [{"key": "1", "name": "골재"}])["routes"] + assert auto["to"] == "site" and auto["linked"] is False and "via_base" not in auto + + +def test_기준점_둘_사슬_셈_거울(): + g, k, c = compute_milestone(CHAIN, None) + # 거래처 → A = 2차 · A → B(기준점 → 기준점) = 2차 · B → 시점 = 1차 · 비포장은 사용자 값 + assert (g["s2"], g["s1"], g["pv"], g["un"]) == (28.3, 0.6, 0, 0.3) + assert (g["paved"], g["unpaved"], g["total"]) == (28.9, 0.3, 29.2) + # 김천역 → B = 2차 · B → 시점 = 1차 · 목적지 중점 = 시점 → 중점 0.8 비포장 + assert (k["s2"], k["s1"], k["un"], k["total"]) == (61.2, 0.6, 0.8, 62.6) + # 시점으로 바로 = 포장 + assert (c["s2"], c["s1"], c["pv"], c["total"]) == (0, 0, 2.0, 2.0) + calc = [B08 + "Milestone_Calc.ts"] + assert _node(calc, f"computeMilestone({json.dumps(CHAIN)}, null)") == [g, k, c] + # 안 이은 기준점에서 사슬 멈춤(그 구간 · 뒤 구간 안 셈) + cut = {**CHAIN, "bases": [CHAIN["bases"][0], {**CHAIN["bases"][1], "linked": False}]} + g2, k2, _ = compute_milestone(cut, None) + assert (g2["s2"], g2["s1"], g2["un"], g2["total"]) == (28.3, 0, 0.3, 28.6) + assert (k2["s2"], k2["total"]) == (61.2, 62.0) + assert _node(calc, f"computeMilestone({json.dumps(cut)}, null)") == compute_milestone(cut, None) + # 선 자리 — 시점에 닿는 기준점 = base(1차) · 기준점 → 기준점 · 거래처 → 기준점 = via(2차) + kinds = _node( + calc, f"['g', 'A', 'B', 'c', 'start'].map((f) => segKind({json.dumps(CHAIN)}, f))" + ) + assert kinds == ["via", "via", "base", "direct", "mid"] + + +def test_돌기_막음(): + loop = {**CHAIN, "bases": [_base("A", "B", [_leg(None, 1)]), _base("B", "A", [_leg(None, 2)])]} + # 셈 = 방문 집합으로 끊음(A → B → A 멈춤 · 끝없이 안 돎) + assert [b["id"] for b in base_chain(loop, "A")] == ["A", "B"] + (g, *_) = compute_milestone(loop, None) + assert g["total"] == 26.8 + calc = [B08 + "Milestone_Calc.ts"] + assert _node(calc, f"computeMilestone({json.dumps(loop)}, null)") == compute_milestone( + loop, None + ) + self_loop = {**CHAIN, "bases": [_base("A", "A", [_leg(None, 1)])]} + assert [b["id"] for b in base_chain(self_loop, "A")] == ["A"] + # 연결 놓을 때 = 돌기면 취소(makesLoop) — A → B 사슬에서 B 를 A 에 이으면 돎 + layout = [B08 + "Milestone_Layout.ts"] + d = json.dumps(CHAIN) + got = _node( + layout, + f"[makesLoop({d}, 'B', 'A'), makesLoop({d}, 'A', 'B'), makesLoop({d}, 'B', 'site')," + f" makesLoop({d}, 'A', 'A'), chainOf({d}, 'A').map((b) => b.id)," + f" kmSegments({d}, 'g', [127.8, 35.6]).map((s) => s.text)]", + ) + assert got[:4] == [True, False, False, True] + assert got[4] == ["A", "B"] and got[5] == ["g → A", "A → B", "B → 시점"] + + +def test_화면_기준점_여럿_등록_삭제(): + modal = _read(B08 + "UI_Milestone_Modal.ts") + assert "fresh.bases.push({" in modal and 'to: "site",' in modal + assert "기준위치(있음" not in modal # 254 기준점 여럿 — 고르기 막음 없음 + # 지운 기준점에 이은 상자 = 안 이음 + assert "if (o.to === base.id)" in modal + state = _read(B08 + "UI_Milestone_State.ts") + assert "migrateMilestone(" in state and "bases: []," in state + page = _read(B08 + "UI_Milestone.ts") + assert "data.bases.length > 0" in page and 'to: "site",' in page + for name in ("Milestone_Calc.ts", "Milestone_Layout.ts", "UI_Milestone_Diagram.ts", + "UI_Milestone_Modal.ts", "UI_Milestone_State.ts", "UI_Milestone_LegModal.ts", + "UI_Milestone_Route.ts", "UI_Milestone.ts"): # fmt: skip + text = _read(B08 + name) + assert "data.base." not in text and ".via_base" not in text and ".base.legs" not in text