/* ============================================================================= * B08_Quantity_Milestone_Calc.ts * B08 거리이정표 표 합(PLAN 244-1) — 화면 없음 · 저장 안 함 · import 없음(시험이 node 로 돌림). * * ⚠ 짝 = `B08_Quantity_Milestone_Calc.py`(같은 식) — 한쪽을 고치면 다른 쪽도 · * 거울 시험 `resources/tester/test_244_1_b08_milestone.py`. * 조작 중은 이 파일이 바로 셈 · [저장] · 읽기 때 서버가 파이썬 짝으로 다시 셈. * * 표 한 줄 = 경로(업체) 구간 + 기준점 사슬 구간들(254 · 거래처 `to` → 기준점 `to` → … → 시점 · * `baseChain` · 방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤) + (목적지 = 중점이면 * 시점 → 중점 = 중점 측점 km) · 도로 구분 셋마다 합 · 포장 = 2차 + 1차 · 합계 = 포장 + 비포장 · * 끝수 = 소수 셋째 자리(반올림) * 구간 km = 사용자 값 · 없으면 [길찾기] 자동 값(244-7) * 도로 구분(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 · 254) — 거래처 · 기준점 → 기준점(via) · 기준점 → 시점(base) · 거래처 → 시점(direct) · * 시점 → 중점(mid) */ export type SegKind = "via" | "base" | "direct" | "mid"; /** 포장 종류 = 선 자리로 정함(152) — → 기준점 = 2차 · 기준점 → 시점 = 1차 · 거래처 → 시점 = 포장 */ export const PAVED: Record = { via: "2차포장", base: "1차포장", direct: "포장", mid: "포장", }; /** 그 구간 도로 구분 — 사용자는 비포장만 고름 · 그 밖(빈 값 · 옛 값) = 자리의 포장 종류 */ export const roadOf = (road: Road | null | undefined, kind: SegKind): Road => road === "비포장" ? "비포장" : PAVED[kind]; /** 도로 구분을 선 자리 포장 종류로 바꾼 사본(152) — 다이어그램 선 글 · 그림용(저장 값은 그대로) */ export function withRoads(data: MilestoneData): MilestoneData { const fix = (legs: Leg[], kind: SegKind): Leg[] => legs.map((leg) => ({ ...leg, road: roadOf(leg.road, kind) })); return { ...data, site: { ...data.site, mid_road: roadOf(data.site.mid_road, "mid") }, bases: data.bases.map((b) => ({ ...b, legs: fix(b.legs, baseKind(b)) })), routes: data.routes.map((r) => ({ ...r, legs: fix(r.legs, routeKind(r)) })), }; } /** 이음 대상 = 시점(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"; const base = data.bases.find((b) => b.id === from); if (base) return baseKind(base); const route = data.routes.find((r) => r.id === from); 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; export type Dir = (typeof DIRS)[number]; export type Dest = "start" | "mid"; export interface Leg { /** null = 사용자가 안 고름(자동 판정 없음 · 244-7) */ road: Road | null; /** 사용자 값 — 있으면 우선 */ km: number | null; /** [길찾기] 자동 값(km 만 저장 · 경로 좌표 · 응답은 저장 안 함) */ auto_km?: number | null; } export interface MilestoneRow { item: string; dest: Dest; note: string; } export interface MilestoneRoute { id: string; /** 업체 이름 = 하위 탭 이름 */ name: string; address: string; /** 잇는 곳(254) — "site"(시점으로 바로) · 기준점 id */ to: string; dir: Dir | null; lon?: number | null; lat?: number | null; /** 다이어그램에 끌어 놓은 자리(153 · 왼 위 · 그림 좌표) — 없으면 방위 자동 자리 */ pos?: [number, number] | null; /** 선 이음(155) — false = 아직 안 이음(요소 추가 뒤 · 연결 점으로 이음 · 구간 km 안 셈) · 없음 = 이음(옛 자료) */ linked?: boolean; /** 수기 입력(155) — 카카오 길찾기 안 함 · km 손 입력만 */ manual?: boolean; /** 운반 묶음 키(251 · 총괄자재 · B09 운반 줄과 이음) — 없음 = 사용자 [요소 추가] 상자 */ 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; /** 길찾기용 시점 덮개(255-2 · 경로 탭 시점 끌기 · 노선은 그대로) — 없음 = 노선 첫 점 */ start_pin?: { lon: number; lat: number } | null; }; bases: MilestoneBase[]; routes: MilestoneRoute[]; } export interface MilestoneTableRow { route: string; index: number; item: string; company: string; dest: Dest; s2: number; s1: number; pv: number; un: number; paved: number; unpaved: number; total: number; note: string; /** 도로 구분 안 고른 구간이 있음 */ pick: boolean; } type Key = "s2" | "s1" | "pv" | "un"; const KEYS: Record = { "2차포장": "s2", "1차포장": "s1", 포장: "pv", 비포장: "un" }; /** 소수 셋째 자리 반올림(파이썬 짝 floor(x × 1000 + 0.5) 과 같음) */ export const r3 = (value: number): number => Math.floor(value * 1000 + 0.5) / 1000; /** 구간 km — 사용자 값 우선 · 없으면 자동 값 */ export const legKm = (leg: Leg): number | null => leg.km ?? leg.auto_km ?? null; /** 도로 구분마다 더함 — 구간 자리로 포장 종류(152 · 안 고른 구간 없음) */ function addLegs(sums: Record, legs: Leg[] | undefined, kind: SegKind): void { for (const leg of legs ?? []) sums[KEYS[roadOf(leg.road, kind)]] += Number(legKm(leg) ?? 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( raw: MilestoneData, midDefaultM: number | null, ): MilestoneTableRow[] { 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 }; 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({ route: route.id, index, item: row.item ?? "", company: route.name ?? "", dest: row.dest ?? "start", s2: r3(sums.s2), s1: r3(sums.s1), pv: r3(sums.pv), un: r3(sums.un), paved: r3(paved), unpaved: r3(sums.un), total: r3(paved + sums.un), note: row.note ?? "", pick: false, }); }); } return out; }