Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
268 lines
11 KiB
TypeScript
268 lines
11 KiB
TypeScript
/* =============================================================================
|
||
* 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<SegKind, Road> = {
|
||
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<string>();
|
||
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<MilestoneBase> & { enabled?: boolean };
|
||
type OldData = Partial<Omit<MilestoneData, "routes">> & {
|
||
base?: OldBase | null;
|
||
routes?: Array<Partial<MilestoneRoute> & { 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<string, unknown> | 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<string, Key> = { "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<Key, number>, 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;
|
||
}
|