Merge remote-tracking branch 'origin/dev' into sub_laptop_8
This commit is contained in:
@@ -148,6 +148,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
if (event.button === 1) event.preventDefault();
|
||||
});
|
||||
svg.addEventListener("pointerdown", (event) => {
|
||||
// 누름마다 풂 — 가운데 단추 팬 뒤엔 click 이 안 와 `moved` 가 남아 왼쪽 누름을 삼켰음(259-1)
|
||||
moved = false;
|
||||
// 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다.
|
||||
if (!(options.panStart ? options.panStart(event) : event.button === 1)) return;
|
||||
event.preventDefault();
|
||||
|
||||
@@ -453,10 +453,10 @@
|
||||
background: color-mix(in srgb, var(--color-success) 18%, var(--color-surface));
|
||||
}
|
||||
|
||||
/* 미연결 — 연한 노랑 배경 + 노랑 테두리(경고 · 밝음 · 어두움 같은 토큰). */
|
||||
/* 미연결 — 옅은 회색 배경 + 회색 테두리(구조물 리스트 램프 색 · 밝음 · 어두움 같은 토큰 · 258-3). */
|
||||
.b05-structure__summary-name:not(.is-assigned) {
|
||||
border-color: var(--color-warning);
|
||||
background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface));
|
||||
border-color: var(--color-text-muted);
|
||||
background: color-mix(in srgb, var(--color-text-muted) 12%, var(--color-surface));
|
||||
}
|
||||
|
||||
.b05-structure__summary .b05-route__irregular-btn {
|
||||
|
||||
@@ -638,7 +638,7 @@ export type CrossCellSource =
|
||||
|
||||
/** [상세] 고른 선 조각(209-1) — 어느 선 · 두 끝 [offset, 표고](m). */
|
||||
export interface CrossPickPiece {
|
||||
line: "design" | "ground" | "spoil" | "structure";
|
||||
line: "design" | "ground" | "spoil" | "structure" | "rock";
|
||||
start: [number, number];
|
||||
end: [number, number];
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ class BoxAdjustPatch(BaseModel):
|
||||
class CrossPickPiece(BaseModel):
|
||||
"""[상세] 고른 선 조각(209-1) — 어느 선 · 두 끝(m · offset, 표고)."""
|
||||
|
||||
line: Literal["design", "ground", "spoil", "structure"]
|
||||
line: Literal["design", "ground", "spoil", "structure", "rock"]
|
||||
start: list[float] = Field(..., min_length=2, max_length=2)
|
||||
end: list[float] = Field(..., min_length=2, max_length=2)
|
||||
|
||||
|
||||
@@ -44,6 +44,16 @@ export function groundRuns(samples: ReadonlyArray<SectionSample> | undefined): P
|
||||
const polyline = (raw: ReadonlyArray<{ offset_m: number; elevation_m: number }> | undefined) =>
|
||||
(raw ?? []).map((p): Pt => [p.offset_m, p.elevation_m]).filter((p) => p.every(Number.isFinite));
|
||||
|
||||
/** 암 경계선 — 원지반선 + 오프셋(암 프리셋만 · 카드 `appendRockBoundaryOverlay` · 서버 `rock_runs`). */
|
||||
export function rockRuns(
|
||||
design: CrossDesign | undefined,
|
||||
samples: ReadonlyArray<SectionSample> | undefined,
|
||||
): Pt[][] {
|
||||
const offset = design?.rock_boundary_offset_m;
|
||||
if (design?.geometry_preset !== "rock" || typeof offset !== "number") return [];
|
||||
return groundRuns(samples).map((run) => run.map(([x, y]): Pt => [x, y + offset]));
|
||||
}
|
||||
|
||||
/** 서버가 아는 선(서버 `pick_lines`) — 고른 조각 위치 검산 대상. */
|
||||
export function pickLines(
|
||||
design: CrossDesign | undefined,
|
||||
@@ -53,6 +63,7 @@ export function pickLines(
|
||||
design: [polyline(design?.design_line)],
|
||||
ground: groundRuns(samples),
|
||||
spoil: [polyline(design?.spoil_fill_line)],
|
||||
rock: rockRuns(design, samples),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Detail_Select.ts
|
||||
* [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선을 만나는 점에서 조각으로
|
||||
* [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선 · 암 경계선(259-1)을 만나는 점에서 조각으로
|
||||
* 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 ·
|
||||
* Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴):
|
||||
* 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이(안 닫히면 「닫히지 않음」).
|
||||
@@ -79,6 +79,8 @@ export function collectPieces(elements: Element[], axes: CrossAxesOptions): Spli
|
||||
else if (el.classList.contains("b06-chart__cross-profile"))
|
||||
others.push({ line: "ground", points });
|
||||
else if (el.classList.contains("b06-chart__spoil-fill")) others.push({ line: "spoil", points });
|
||||
else if (el.classList.contains("b06-chart__rock-boundary"))
|
||||
others.push({ line: "rock", points });
|
||||
}
|
||||
const straight = mergeStraightPieces(runs).map((piece): SplitPiece => ({
|
||||
line: piece.structure ? "structure" : "design",
|
||||
@@ -102,6 +104,7 @@ const LINE_NAME: Record<SplitPiece["line"], string> = {
|
||||
structure: "구조물 선",
|
||||
ground: "원지반선",
|
||||
spoil: "사토장선",
|
||||
rock: "암 경계선",
|
||||
};
|
||||
|
||||
/** 고른 것 값 두 줄 — 하나 = 길이 · 경사비율 / 수평 · 높이(카드 꼴) · 여럿 = 길이 합 / 넓이. */
|
||||
@@ -111,7 +114,11 @@ export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPie
|
||||
const [piece] = pieces;
|
||||
const from = piece.points[0];
|
||||
const to = piece.points[piece.points.length - 1];
|
||||
if (design && piece.points.length === 2 && piece.line !== "ground" && piece.line !== "spoil")
|
||||
if (
|
||||
design &&
|
||||
piece.points.length === 2 &&
|
||||
(piece.line === "design" || piece.line === "structure")
|
||||
)
|
||||
return pieceLabel(design, {
|
||||
from: { offset: from[0], elevation: from[1] },
|
||||
to: { offset: to[0], elevation: to[1] },
|
||||
|
||||
@@ -181,12 +181,21 @@ def ring_area(paths: list[list[Point]]) -> float | None:
|
||||
return area if area > 1e-9 else None
|
||||
|
||||
|
||||
def rock_runs(design: dict[str, Any], samples: Any = None) -> list[list[Point]]:
|
||||
"""암 경계선 — 원지반선 + `rock_boundary_offset_m`(암 프리셋만 · 카드 그리기와 같은 규칙)."""
|
||||
offset = _num(design.get("rock_boundary_offset_m"))
|
||||
if design.get("geometry_preset") != "rock" or offset is None:
|
||||
return []
|
||||
return [[(x, y + offset) for x, y in run] for run in ground_runs(samples)]
|
||||
|
||||
|
||||
def pick_lines(design: dict[str, Any], samples: Any = None) -> dict[str, list[list[Point]]]:
|
||||
"""서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선)."""
|
||||
"""서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선 · 암 경계선)."""
|
||||
return {
|
||||
"design": [_design_line(design)],
|
||||
"ground": ground_runs(samples),
|
||||
"spoil": [_polyline(design.get("spoil_fill_line"))],
|
||||
"rock": rock_runs(design, samples),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -127,3 +127,37 @@ def with_haul_routes(data: dict[str, Any], groups: list[dict[str, Any]]) -> dict
|
||||
}
|
||||
)
|
||||
return {**data, "routes": routes}
|
||||
|
||||
|
||||
def route_bill_key(route: dict[str, Any], index: int = 0) -> str:
|
||||
"""거리이정표 행 → B09 운반공 줄 키(253-1) — 자동 상자 = 옛 키 · 사용자 상자 = `haul:r-{id}` ·
|
||||
한 상자 둘째 행부터 `haul:r-{id}.{n}`(경로 id 에 「.」 없음 — 안 겹침)."""
|
||||
if route.get("haul"):
|
||||
return bill_key(str(route["haul"]))
|
||||
return f"haul:r-{route.get('id')}" + (f".{index}" if index else "")
|
||||
|
||||
|
||||
def haul_rows(data: dict[str, Any], table: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""거리이정표 표 행 하나 = 운반공 줄 하나(253-1) — [{key, name, ref, user, L1, L2}] ·
|
||||
L1 = 포장 계(2차 + 1차 + 포장) · L2 = 비포장(`compute_milestone` 결과) ·
|
||||
user = 사용자 상자(자동 상자는 총괄자재 묶음 줄이 이미 있음 · 둘째 행은 안 씀)."""
|
||||
routes = {r.get("id"): r for r in data.get("routes") or []}
|
||||
out, seen = [], set()
|
||||
for row in table:
|
||||
route = routes.get(row.get("route")) or {}
|
||||
key = route_bill_key(route, int(row.get("index") or 0))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
item = str(row.get("item") or "").strip()
|
||||
out.append(
|
||||
{
|
||||
"key": key,
|
||||
"name": f"{item}운반" if item else "운반",
|
||||
"ref": route.get("ref"),
|
||||
"user": not route.get("haul"),
|
||||
"L1": row.get("paved"),
|
||||
"L2": row.get("unpaved"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""B08 운반 사이클시간(PLAN 253-2) — 건설품셈 8-2-8 덤프트럭 · 거리이정표 거리 → 운반 로직 입력.
|
||||
|
||||
⚠ 짝 = `B08_Quantity_Haul_Cycle.ts`(같은 식) — 한쪽을 고치면 다른 쪽도 ·
|
||||
거울 시험 `resources/tester/test_253_2_haul_cycle.py`.
|
||||
조작 중은 TS 짝(「운반 단가」 모달)이 바로 셈 · B09 셈 때 서버가 이 파일로 다시 채움(정본).
|
||||
|
||||
식(거창 25 설계서 단가산출 1308~1347행 꼴 · 끝수 = 소수 둘째 자리 반올림)
|
||||
q = T / rt × f · n = q / (qs × k) · t1 = Cms × n / (60 × Es)
|
||||
t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · Cm = t1 + t2 + t3 + t4 + t5 + t6
|
||||
L1 = 거리이정표 그 행 포장 계(2차 + 1차 + 포장) · L2 = 비포장 · 속도 0 = 그 항 0.
|
||||
로직 입력 채움(이름으로) — `Cm` · `사이클시간` = Cm · `t2` = t2 · `운반거리` = L1 + L2 ·
|
||||
`own` = 사용자가 덮은 칸(L1 · L2 · t1 · 로직 입력 이름) — 그 값이 이김.
|
||||
기본값 = 거창 설계서(혼합골재 덤프 15톤) · 속도 · t 표 = master_data CC000054~058(화면이 고름).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
DEFAULTS: dict[str, float] = {
|
||||
"L1": 0.0,
|
||||
"L2": 0.0,
|
||||
"V1": 30.0,
|
||||
"V2": 35.0,
|
||||
"V3": 10.0,
|
||||
"V4": 15.0,
|
||||
"T": 15.0,
|
||||
"rt": 1.7,
|
||||
"f": 1.0,
|
||||
"qs": 1.72,
|
||||
"k": 1.0,
|
||||
"Es": 0.6,
|
||||
"Cms": 37.4,
|
||||
"t3": 0.8,
|
||||
"t4": 0.42,
|
||||
"t5": 0.5,
|
||||
"t6": 1.5,
|
||||
}
|
||||
#: 로직 입력 이름 → 셈 결과 칸
|
||||
FILL = {"Cm": "cm", "사이클시간": "cm", "t2": "t2", "운반거리": "L"}
|
||||
|
||||
|
||||
def r2(value: float) -> float:
|
||||
"""소수 둘째 자리 반올림(0.5 올림 · TS `Math.round` 와 같음)."""
|
||||
return math.floor(value * 100 + 0.5) / 100
|
||||
|
||||
|
||||
def _div(a: float, b: float) -> float:
|
||||
return a / b if b else 0.0
|
||||
|
||||
|
||||
def _num(value: Any) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
return float(value) if math.isfinite(value) else None
|
||||
|
||||
|
||||
def cycle_time(inputs: dict[str, Any] | None) -> dict[str, float]:
|
||||
"""사이클 입력(빈 칸 = 기본값 · `t1` 있으면 그 값) → {q, n, t1, t2, cm, L}."""
|
||||
given = {k: _num(v) for k, v in (inputs or {}).items()}
|
||||
v = {k: given[k] if given.get(k) is not None else d for k, d in DEFAULTS.items()}
|
||||
q = r2(_div(v["T"], v["rt"]) * v["f"])
|
||||
n = r2(_div(q, v["qs"] * v["k"]))
|
||||
t1 = given.get("t1")
|
||||
t1 = r2(_div(v["Cms"] * n, 60 * v["Es"])) if t1 is None else t1
|
||||
l1, l2 = v["L1"], v["L2"]
|
||||
t2 = r2((_div(l1, v["V1"]) + _div(l1, v["V2"]) + _div(l2, v["V3"]) + _div(l2, v["V4"])) * 60)
|
||||
cm = r2(t1 + t2 + v["t3"] + v["t4"] + v["t5"] + v["t6"])
|
||||
return {
|
||||
"q": q,
|
||||
"n": n,
|
||||
"t1": t1,
|
||||
"t2": t2,
|
||||
"cm": cm,
|
||||
"L": math.floor((l1 + l2) * 1000 + 0.5) / 1000,
|
||||
}
|
||||
|
||||
|
||||
def cycle_inputs(ref: dict[str, Any] | None, dist: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""연결 `ref.cycle` + 거리이정표 거리(`dist{L1, L2}`) — 덮은 L1 · L2(`ref.own`)는 그대로."""
|
||||
cycle = dict((ref or {}).get("cycle") or {})
|
||||
own = set((ref or {}).get("own") or [])
|
||||
for name in ("L1", "L2"):
|
||||
if name not in own and _num((dist or {}).get(name)) is not None:
|
||||
cycle[name] = dist[name]
|
||||
if "t1" not in own:
|
||||
cycle.pop("t1", None)
|
||||
return cycle
|
||||
|
||||
|
||||
def fill_given(
|
||||
ref: dict[str, Any], names: list[str], dist: dict[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
"""로직 입력값 다시 채움 — `names` = 그 로직 입력 이름 · 덮은 칸(`ref.own`)은 그대로."""
|
||||
own = set(ref.get("own") or [])
|
||||
got = cycle_time(cycle_inputs(ref, dist))
|
||||
given = dict(ref.get("given") or {})
|
||||
for name, slot in FILL.items():
|
||||
if name in names and name not in own:
|
||||
given[name] = got[slot]
|
||||
return given
|
||||
@@ -0,0 +1,93 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_Haul_Cycle.ts
|
||||
* B08 운반 사이클시간(PLAN 253-2) — 건설품셈 8-2-8 덤프트럭 · 거리이정표 거리 → 운반 로직 입력.
|
||||
*
|
||||
* ⚠ 짝 = `B08_Quantity_Haul_Cycle.py`(같은 식) — 한쪽을 고치면 다른 쪽도 ·
|
||||
* 거울 시험 `resources/tester/test_253_2_haul_cycle.py`.
|
||||
* 조작 중은 이 파일(「운반 단가」 모달)이 바로 셈 · B09 셈 때 서버가 짝으로 다시 채움(정본).
|
||||
*
|
||||
* 식 = q = T / rt × f · n = q / (qs × k) · t1 = Cms × n / (60 × Es) ·
|
||||
* t2 = (L1/V1 + L1/V2 + L2/V3 + L2/V4) × 60 · Cm = t1 + … + t6 · 끝수 = 소수 둘째 자리 ·
|
||||
* 속도 0 = 그 항 0 · 로직 입력 채움 = Cm · 사이클시간 = Cm · t2 · 운반거리 = L1 + L2 ·
|
||||
* `own` = 사용자가 덮은 칸(그 값이 이김).
|
||||
* ========================================================================== */
|
||||
|
||||
export const CYCLE_DEFAULTS: Record<string, number> = {
|
||||
L1: 0,
|
||||
L2: 0,
|
||||
V1: 30,
|
||||
V2: 35,
|
||||
V3: 10,
|
||||
V4: 15,
|
||||
T: 15,
|
||||
rt: 1.7,
|
||||
f: 1,
|
||||
qs: 1.72,
|
||||
k: 1,
|
||||
Es: 0.6,
|
||||
Cms: 37.4,
|
||||
t3: 0.8,
|
||||
t4: 0.42,
|
||||
t5: 0.5,
|
||||
t6: 1.5,
|
||||
};
|
||||
/** 로직 입력 이름 → 셈 결과 칸 */
|
||||
export const CYCLE_FILL: Record<string, "cm" | "t2" | "L"> = {
|
||||
Cm: "cm",
|
||||
사이클시간: "cm",
|
||||
t2: "t2",
|
||||
운반거리: "L",
|
||||
};
|
||||
|
||||
export interface CycleResult {
|
||||
q: number;
|
||||
n: number;
|
||||
t1: number;
|
||||
t2: number;
|
||||
cm: number;
|
||||
L: number;
|
||||
}
|
||||
export interface CycleRef {
|
||||
given?: Record<string, unknown> | null;
|
||||
cycle?: Record<string, unknown> | null;
|
||||
own?: string[] | null;
|
||||
}
|
||||
export type Dist = { L1?: number | null; L2?: number | null } | null | undefined;
|
||||
|
||||
/** 소수 둘째 자리 반올림(0.5 올림 · 파이썬 짝 `r2`) */
|
||||
export const r2 = (value: number): number => Math.floor(value * 100 + 0.5) / 100;
|
||||
const div = (a: number, b: number): number => (b ? a / b : 0);
|
||||
const num = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
|
||||
/** 사이클 입력(빈 칸 = 기본값 · t1 있으면 그 값) → {q, n, t1, t2, cm, L} */
|
||||
export function cycleTime(inputs: Record<string, unknown> | null | undefined): CycleResult {
|
||||
const v: Record<string, number> = {};
|
||||
for (const [k, d] of Object.entries(CYCLE_DEFAULTS)) v[k] = num(inputs?.[k]) ?? d;
|
||||
const q = r2(div(v.T, v.rt) * v.f);
|
||||
const n = r2(div(q, v.qs * v.k));
|
||||
const t1 = num(inputs?.t1) ?? r2(div(v.Cms * n, 60 * v.Es));
|
||||
const t2 = r2((div(v.L1, v.V1) + div(v.L1, v.V2) + div(v.L2, v.V3) + div(v.L2, v.V4)) * 60);
|
||||
const cm = r2(t1 + t2 + v.t3 + v.t4 + v.t5 + v.t6);
|
||||
return { q, n, t1, t2, cm, L: Math.floor((v.L1 + v.L2) * 1000 + 0.5) / 1000 };
|
||||
}
|
||||
|
||||
/** 연결 `ref.cycle` + 거리이정표 거리 — 덮은 L1 · L2(`ref.own`)는 그대로 */
|
||||
export function cycleInputs(ref: CycleRef | null | undefined, dist: Dist): Record<string, unknown> {
|
||||
const cycle: Record<string, unknown> = { ...(ref?.cycle || {}) };
|
||||
const own = new Set(ref?.own || []);
|
||||
for (const name of ["L1", "L2"] as const)
|
||||
if (!own.has(name) && num(dist?.[name]) !== null) cycle[name] = dist?.[name];
|
||||
if (!own.has("t1")) delete cycle.t1;
|
||||
return cycle;
|
||||
}
|
||||
|
||||
/** 로직 입력값 다시 채움 — names = 그 로직 입력 이름 · 덮은 칸은 그대로 */
|
||||
export function fillGiven(ref: CycleRef, names: string[], dist: Dist): Record<string, unknown> {
|
||||
const own = new Set(ref.own || []);
|
||||
const got = cycleTime(cycleInputs(ref, dist));
|
||||
const given: Record<string, unknown> = { ...(ref.given || {}) };
|
||||
for (const [name, slot] of Object.entries(CYCLE_FILL))
|
||||
if (names.includes(name) && !own.has(name)) given[name] = got[slot];
|
||||
return given;
|
||||
}
|
||||
@@ -179,7 +179,12 @@ export interface MilestoneBase {
|
||||
}
|
||||
|
||||
export interface MilestoneData {
|
||||
site: { mid_station_m: number | null; mid_road: Road };
|
||||
site: {
|
||||
mid_station_m: number | null;
|
||||
mid_road: Road;
|
||||
/** 길찾기용 시점 덮개(255-2 · 경로 탭 시점 끌기 · 노선은 그대로) — 없음 = 노선 첫 점 */
|
||||
start_pin?: { lon: number; lat: number } | null;
|
||||
};
|
||||
bases: MilestoneBase[];
|
||||
routes: MilestoneRoute[];
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · v
|
||||
캡처는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 = JSON stdin · 출력 = 결과 JSON 한 줄 ·
|
||||
키는 환경 그대로) — 서버(uvicorn · Windows Selector 루프) 안 스레드에선 Playwright 가
|
||||
드라이버 하위 프로세스를 못 만듦(249-1).
|
||||
장소 그림(256-1) = 같은 길 · 작은 지도(480×320 · 표지 하나 · 주소 둘레 줌) → `place/<상자 id>.png`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -37,6 +38,7 @@ NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움"
|
||||
CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠
|
||||
LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색
|
||||
TAIL = " — [그림 변경]으로 넣으세요."
|
||||
PLACE_VIEW = {"w": 480, "h": 320, "level": 4} # 장소 그림(256-1) — 표지 가운데 · 고정 줌
|
||||
|
||||
_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나(2~3초)
|
||||
|
||||
@@ -45,17 +47,22 @@ class CaptureError(Exception):
|
||||
"""까닭 한 줄(키 · 예외 원문 없음)."""
|
||||
|
||||
|
||||
def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str:
|
||||
"""카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail."""
|
||||
def page_html(
|
||||
key: str, lines: list[Any], marks: list[dict[str, Any]], view: dict[str, int] | None = None
|
||||
) -> str:
|
||||
"""카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail.
|
||||
view = 크기 · 줌 고정(장소 그림 · 첫 표지 가운데 · 경로 맞춤 안 함)."""
|
||||
w, h = (view["w"], view["h"]) if view else (WIDTH, HEIGHT)
|
||||
level = view["level"] if view else 7
|
||||
data = json.dumps({"lines": lines, "marks": marks}, ensure_ascii=False).replace("<", "\\u003c")
|
||||
sdk = f"https://dapi.kakao.com/v2/maps/sdk.js?appkey={urllib.parse.quote(key)}&autoload=false"
|
||||
return f"""<!doctype html><html><head><meta charset="utf-8"></head><body style="margin:0">
|
||||
<div id="m" style="width:{WIDTH}px;height:{HEIGHT}px"></div>
|
||||
<div id="m" style="width:{w}px;height:{h}px"></div>
|
||||
<script src="{sdk}" onerror="window.__fail=1"></script>
|
||||
<script>window.kakao && kakao.maps.load(() => {{
|
||||
const D = {data}, K = kakao.maps, at = (p) => new K.LatLng(p[0], p[1]);
|
||||
const pts = [...D.marks.map((m) => [m.lat, m.lon]), ...D.lines.flat()].map(at);
|
||||
const map = new K.Map(document.getElementById("m"), {{ center: pts[0], level: 7 }});
|
||||
const map = new K.Map(document.getElementById("m"), {{ center: pts[0], level: {level} }});
|
||||
D.lines.forEach((l) => new K.Polyline({{ map, path: l.map(at), strokeWeight: 6,
|
||||
strokeColor: "{LINE_COLOR}", strokeOpacity: 0.9 }}));
|
||||
D.marks.forEach((m) => {{
|
||||
@@ -68,9 +75,11 @@ def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str:
|
||||
new K.CustomOverlay({{ map, position: p, content: s, yAnchor: 2.6 }});
|
||||
}});
|
||||
K.event.addListener(map, "tilesloaded", () => (window.__done = true));
|
||||
const b = new K.LatLngBounds();
|
||||
pts.forEach((p) => b.extend(p));
|
||||
map.setBounds(b, 60, 60, 60, 60);
|
||||
if ({"false" if view else "true"}) {{
|
||||
const b = new K.LatLngBounds();
|
||||
pts.forEach((p) => b.extend(p));
|
||||
map.setBounds(b, 60, 60, 60, 60);
|
||||
}}
|
||||
}});</script></body></html>"""
|
||||
|
||||
|
||||
@@ -129,7 +138,7 @@ def capture(job: dict[str, Any]) -> dict[str, Any]:
|
||||
"""자식 프로세스 몸통 — 그림 찍어 `<out>/<route_id>.png` 저장 → {saved, reason}."""
|
||||
key = os.environ.get(KAKAO_JS, "").strip()
|
||||
try:
|
||||
png = shoot(page_html(key, job["lines"], job["marks"]), job["origin"])
|
||||
png = shoot(page_html(key, job["lines"], job["marks"], job.get("view")), job["origin"])
|
||||
except CaptureError as error:
|
||||
return {"saved": False, "reason": str(error)}
|
||||
out_dir = Path(job["out"])
|
||||
@@ -160,25 +169,36 @@ def spawn(job: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def make_route_image(
|
||||
out_dir: Path, route_id: str, lines: list[Any], marks: list[dict[str, Any]]
|
||||
out_dir: Path,
|
||||
route_id: str,
|
||||
lines: list[Any],
|
||||
marks: list[dict[str, Any]],
|
||||
view: dict[str, int] | None = None,
|
||||
what: str = "경로 그림",
|
||||
tail: str = TAIL,
|
||||
) -> dict[str, Any]:
|
||||
"""그 경로 자동 그림 → {saved, reason} — 좌표는 그림에만 쓰고 버림."""
|
||||
key = os.environ.get(KAKAO_JS, "").strip()
|
||||
if not key:
|
||||
return {
|
||||
"saved": False,
|
||||
"reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){TAIL}",
|
||||
"reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){tail}",
|
||||
}
|
||||
if not any(lines) and not marks:
|
||||
return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{TAIL}"}
|
||||
return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{tail}"}
|
||||
origin = (os.environ.get(ORIGIN_KEY, "").strip() or DEFAULT_ORIGIN).rstrip("/")
|
||||
job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks}
|
||||
job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks, "view": view}
|
||||
with _lock:
|
||||
got = spawn({**job, "origin": origin})
|
||||
if not got.get("saved"):
|
||||
return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {got.get('reason')}{TAIL}"}
|
||||
return {"saved": False, "reason": f"{what} 자동 캡처 실패 — {got.get('reason')}{tail}"}
|
||||
return {"saved": True, "reason": None}
|
||||
|
||||
|
||||
def make_place_image(out_dir: Path, place_id: str, mark: dict[str, Any]) -> dict[str, Any]:
|
||||
"""장소 그림(256-1) — 작은 지도 · 표지 하나 → `<out_dir>/<상자 id>.png`."""
|
||||
return make_route_image(out_dir, place_id, [], [mark], PLACE_VIEW, "장소 그림", "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(json.dumps(capture(json.loads(sys.stdin.buffer.read()))))
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
POST /api/projects/{id}/quantity/milestone/image/auto?route= 경로 그림 자동(244-11) — 길 선 ·
|
||||
표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` ·
|
||||
좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄
|
||||
POST /api/projects/{id}/quantity/milestone/place-image?id= 장소 그림(256-1) — 상자 좌표 ·
|
||||
이름 → 작은 지도 캡처 → `place/<상자 id>.png` · 등록 · 수정 · 끌기 뒤 화면이 부름
|
||||
GET /api/projects/{id}/quantity/milestone/file?name= 그림 파일(256-1) — `place/<id>.png`
|
||||
· `auto/<id>.png` · `<id>.<확장자>`(하위 폴더 둘만 · 경로 넘기 막음)
|
||||
GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 ·
|
||||
시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) · ?mid_m= 다이어그램
|
||||
지금 중점 측점(244-12 · 상자 작은 글씨)
|
||||
@@ -24,6 +28,10 @@
|
||||
그림 · `auto/<경로 id>.png` 자동 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴) · 보이는 그림 = 사용자
|
||||
그림 우선. 초기값 층 없음(자동설계가 낳는 값 아님).
|
||||
그림은 올리는 즉시 서버에([저장]과 따로) · [저장] 때 없는 경로의 그림은 지움.
|
||||
장소 그림(256-1) = `place/<상자 id>.png` · [저장] 뒤 서버가 좌표가 바뀐(또는 그림 없는) 상자만
|
||||
다시 캡처(뒤에서 · 지문 = `.prints.json` · 좌표는 해시만) · 없는 상자 그림은 [저장] 때 지움 ·
|
||||
GET 답 `place_images` {상자 id: "place/<id>.png"} · `place_times` {상자 id: 바뀐 시각 ms}
|
||||
(캐시 깨기).
|
||||
공사지 = 시점(노선 첫 점 · 측점 0) · 중점 기본 = 노선 연장 ÷ 2 — 노선은 설계 노선 정본 차례
|
||||
(수정본 → 초기 폴리라인 → 예상노선 → 초기값 · `load_design_route` 와 같은 차례).
|
||||
표 합 = `B08_Quantity_Milestone_Calc`(파이썬 · TS 짝).
|
||||
@@ -34,9 +42,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
@@ -44,7 +54,7 @@ from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
|
||||
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field, ValidationError, model_validator
|
||||
|
||||
@@ -53,7 +63,7 @@ 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 SITE, compute_milestone, migrate_milestone, r3
|
||||
from B08_Quantity.B08_Quantity_Milestone_Capture import make_route_image
|
||||
from B08_Quantity.B08_Quantity_Milestone_Capture import make_place_image, make_route_image
|
||||
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||||
from B08_Quantity.B08_Quantity_Router_Material import material_sheets
|
||||
from common_util.common_util_initial_snapshot import design_route_csv_path
|
||||
@@ -71,6 +81,8 @@ PATH = "/{project_id}/quantity/milestone"
|
||||
FOLDER = Path("B08_Quantity") / "milestone"
|
||||
DATA_FILE = "milestone.json"
|
||||
AUTO = "auto" # 자동 그림 하위 폴더(244-11)
|
||||
PLACE = "place" # 장소 그림 하위 폴더(256-1)
|
||||
PRINTS = ".prints.json" # 그림 지문 {"place/<id>": 해시} — 바뀐 것만 다시 캡처
|
||||
IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
|
||||
ROUTE_ID = r"^[A-Za-z0-9_-]{1,40}$"
|
||||
BOX_IDS = {SITE, "start", "mid"} # 다이어그램 공사지 상자 id — 기준점 · 경로 id 로 못 씀
|
||||
@@ -121,9 +133,15 @@ class Base(Place):
|
||||
linked: bool = True # False = 아직 선 안 이음(요소 추가 뒤)
|
||||
|
||||
|
||||
class LonLat(BaseModel):
|
||||
lon: float = Field(ge=-180, le=180)
|
||||
lat: float = Field(ge=-90, le=90)
|
||||
|
||||
|
||||
class Site(BaseModel):
|
||||
mid_station_m: float | None = Field(default=None, ge=0, le=1_000_000)
|
||||
mid_road: Road = "비포장"
|
||||
start_pin: LonLat | None = None # 255-2 길찾기용 시점 덮개(노선은 그대로)
|
||||
|
||||
|
||||
class Milestone(BaseModel):
|
||||
@@ -227,15 +245,29 @@ def images(root: str | Path, sub: str = "") -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def places(data: dict[str, Any]) -> list[tuple[str, str, float, float]]:
|
||||
"""좌표가 정해진 상자 → [(상자 id, 이름, 경도, 위도)] — 거래처 = 경로 id · 기준점 = bases id."""
|
||||
boxes = [
|
||||
(b["id"], b.get("name") or "", b.get("lon"), b.get("lat"))
|
||||
for b in [*data.get("routes", []), *data.get("bases", [])]
|
||||
]
|
||||
return [(i, n, lon, lat) for i, n, lon, lat in boxes if lon is not None and lat is not None]
|
||||
|
||||
|
||||
def payload(root: str | Path, data: dict[str, Any]) -> dict[str, Any]:
|
||||
site = site_info(root, data["site"]["mid_station_m"])
|
||||
auto = images(root, AUTO)
|
||||
place = images(root, PLACE)
|
||||
return {
|
||||
"data": data,
|
||||
"site": site,
|
||||
"table": compute_milestone(data, site["mid_default_m"]),
|
||||
"images": {**auto, **images(root)}, # 보이는 그림 = 사용자 그림 우선
|
||||
"auto_images": auto,
|
||||
"place_images": place,
|
||||
"place_times": {
|
||||
stem: int((folder(root) / name).stat().st_mtime * 1000) for stem, name in place.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -245,13 +277,60 @@ def save_data(root: str | Path, body: Milestone) -> dict[str, Any]:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(path / DATA_FILE, data)
|
||||
keep = {route["id"] for route in data["routes"]}
|
||||
for sub in ("", AUTO):
|
||||
for sub, ids in (("", keep), (AUTO, keep), (PLACE, {p[0] for p in places(data)})):
|
||||
for stem, name in images(root, sub).items():
|
||||
if stem not in keep:
|
||||
if stem not in ids:
|
||||
(path / name).unlink(missing_ok=True)
|
||||
return payload(root, data)
|
||||
|
||||
|
||||
def _print(*parts: Any) -> str:
|
||||
"""지문 — 좌표 · 이름 해시(좌표 원문은 안 남김)."""
|
||||
return hashlib.sha1(json.dumps(parts, ensure_ascii=False).encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _read_prints(root: str | Path) -> dict[str, str]:
|
||||
try:
|
||||
return json.loads((folder(root) / PRINTS).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _set_print(root: str | Path, key: str, value: str | None) -> None:
|
||||
"""지문 한 칸 적음 · None = 지움(다음 [저장]에 다시 캡처)."""
|
||||
with _prints_lock:
|
||||
prints = _read_prints(root)
|
||||
if prints.get(key) == value:
|
||||
return
|
||||
if value is None:
|
||||
prints.pop(key, None)
|
||||
else:
|
||||
prints[key] = value
|
||||
atomic_write_json(folder(root) / PRINTS, prints)
|
||||
|
||||
|
||||
_prints_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 지문 파일 읽고 쓰기 한 번에 하나
|
||||
|
||||
|
||||
def place_image(root: str | Path, place_id: str, name: str, lon: float, lat: float) -> dict:
|
||||
"""장소 그림 한 장 캡처 → 지문 적음 → {saved, reason}."""
|
||||
got = make_place_image(folder(root) / PLACE, place_id, {"name": name, "lon": lon, "lat": lat})
|
||||
if got["saved"]:
|
||||
_set_print(root, f"{PLACE}/{place_id}", _print(name, lon, lat))
|
||||
return got
|
||||
|
||||
|
||||
def refresh_images(root: str | Path) -> None:
|
||||
"""[저장] 뒤(뒤에서) — 지문이 바뀌었거나 그림이 없는 상자만 다시 캡처 ·
|
||||
실패 = 다음 [저장]에 다시."""
|
||||
data = read_data(root)
|
||||
prints = _read_prints(root)
|
||||
have = images(root, PLACE)
|
||||
for place_id, name, lon, lat in places(data):
|
||||
if prints.get(f"{PLACE}/{place_id}") != _print(name, lon, lat) or place_id not in have:
|
||||
place_image(root, place_id, name, lon, lat)
|
||||
|
||||
|
||||
def _route_id(route: str) -> str:
|
||||
try:
|
||||
return Route(id=route).id
|
||||
@@ -275,9 +354,13 @@ async def milestone_read(project_id: UUID) -> dict[str, Any]:
|
||||
|
||||
|
||||
@router.put(PATH)
|
||||
async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]:
|
||||
async def milestone_save(
|
||||
project_id: UUID, body: Milestone, tasks: BackgroundTasks
|
||||
) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
return await asyncio.to_thread(save_data, root, body)
|
||||
out = await asyncio.to_thread(save_data, root, body)
|
||||
tasks.add_task(refresh_images, root) # 답 뒤 캡처(상자마다 2~3초) — 다음 GET 에 보임
|
||||
return out
|
||||
|
||||
|
||||
@router.get(PATH + "/site-address")
|
||||
@@ -390,15 +473,36 @@ async def milestone_image_auto(
|
||||
return {"name": f"{AUTO}/{route_id}.png" if got["saved"] else None, "reason": got["reason"]}
|
||||
|
||||
|
||||
FILE_NAME = r"^(?:(?:auto|place)/)?[A-Za-z0-9_-]{1,40}\.(?:png|jpe?g|gif|webp|bmp)$"
|
||||
|
||||
|
||||
@router.post(PATH + "/place-image")
|
||||
async def milestone_place_image(project_id: UUID, body: Mark, id: str = Query(...)) -> dict:
|
||||
"""장소 그림(256-1) — 등록 · 수정 · 끌기 뒤 화면이 부름 → place/<id>.png · 좌표는 저장 안 함."""
|
||||
place_id = _route_id(id)
|
||||
root = await _project_root(project_id)
|
||||
got = await asyncio.to_thread(place_image, root, place_id, body.name, body.lon, body.lat)
|
||||
if not got["saved"]:
|
||||
return {"name": None, "time": None, "reason": got["reason"]}
|
||||
name = f"{PLACE}/{place_id}.png"
|
||||
time_ms = int((folder(root) / name).stat().st_mtime * 1000)
|
||||
return {"name": name, "time": time_ms, "reason": None}
|
||||
|
||||
|
||||
@router.get(PATH + "/file")
|
||||
async def milestone_file(project_id: UUID, name: str = Query(..., pattern=FILE_NAME)):
|
||||
"""그림 파일(256-1) — 하위 폴더 auto · place 만(패턴이 경로 넘기 막음)."""
|
||||
root = await _project_root(project_id)
|
||||
path = folder(root) / name
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="그림이 없습니다.")
|
||||
return FileResponse(path)
|
||||
|
||||
|
||||
KAKAO_URL = "https://apis-navi.kakaomobility.com/v1/directions"
|
||||
KAKAO_KEY = "KAKAO_REST_API_KEY"
|
||||
|
||||
|
||||
class LonLat(BaseModel):
|
||||
lon: float = Field(ge=-180, le=180)
|
||||
lat: float = Field(ge=-90, le=90)
|
||||
|
||||
|
||||
class Directions(BaseModel):
|
||||
origin: LonLat
|
||||
destination: LonLat
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
roadOptions,
|
||||
segmentPath,
|
||||
siteInfo,
|
||||
startPoint,
|
||||
} from "./B08_Quantity_UI_Milestone_State";
|
||||
|
||||
const km = (text: string): number | null => {
|
||||
@@ -147,7 +148,7 @@ function openMidLeg(projectId: string): void {
|
||||
/** 연결선 누름 — 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;
|
||||
const start = (): [number, number] | null => startPoint(liveMilestone(projectId)); // 255-2 덮개
|
||||
const seg = segmentEnds(liveMilestone(projectId), from, start());
|
||||
if (!seg) return;
|
||||
openModal({
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* 분류 기준위치 = 기준점(254 · 여럿 · 새 `bases` 원소 · 항목 · 목적지 없음 · 등록 = 상자만 · 연결 점으로 이음) ·
|
||||
* 방위 = 소재지 좌표로 자동 자리 · 기준점 [삭제] = 그 기준점에 이은 거래처 · 기준점은 안 이음으로
|
||||
* 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번
|
||||
* 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도
|
||||
* 시점(공사지) 상자 = 중점 측점 km(저장 m · 255-1) · 아래 카카오 지도(시점 · 중점 표지)
|
||||
* 조작 = 캐시 · [저장] 때 서버
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { buildAddressMap } from "../B01_Dashboard/B01_Dashboard_UI_MapPreview";
|
||||
import {
|
||||
roadOf,
|
||||
type Dest,
|
||||
@@ -33,7 +32,9 @@ import {
|
||||
type MilestoneRow,
|
||||
type Road,
|
||||
} from "./B08_Quantity_Milestone_Calc";
|
||||
import { setLeg } from "./B08_Quantity_Milestone_Layout";
|
||||
import { request } from "./B08_Quantity_UI_Hydro";
|
||||
import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap";
|
||||
import { placePicker } from "./B08_Quantity_UI_Milestone_Place";
|
||||
import {
|
||||
findSegmentKm,
|
||||
@@ -245,27 +246,26 @@ export interface SiteAddress {
|
||||
mid_address: string | null;
|
||||
}
|
||||
|
||||
/** 시점(공사지) 상자 — 프로젝트 등록 주소 · VWorld 주소 · 아래 지도(시점 표지) */
|
||||
/** 시점(공사지) 상자 — 중점 측점(km · 저장은 m) · 시점 → 중점 도로 · 아래 카카오 지도(시점 · 중점 표지) */
|
||||
export function openSiteInfo(projectId: string): void {
|
||||
openModal({
|
||||
title: "공사지 — 시점 · 중점",
|
||||
closeLabel: "닫기",
|
||||
dialogClass: "b08-milestone__modal",
|
||||
mount: (body, close, actions) => {
|
||||
const text = (label: string, value: string): HTMLElement =>
|
||||
el("p", { className: "b08-options__hint", text: `${label} — ${value}` });
|
||||
// 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6)
|
||||
// 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6) · 보이고 받는 것만 km(255-1)
|
||||
const data = liveMilestone(projectId);
|
||||
const site = siteInfo();
|
||||
const km = (m: number | null | undefined): string =>
|
||||
m == null ? "-" : (m / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||
const mid = createInputField({
|
||||
label: "중점 측점 (m)",
|
||||
label: "중점 측점 (km)",
|
||||
type: "number",
|
||||
min: 0,
|
||||
compact: true,
|
||||
});
|
||||
mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m) : "";
|
||||
mid.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m);
|
||||
mid.input.step = "0.001";
|
||||
mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m / 1000) : "";
|
||||
mid.input.value =
|
||||
data.site.mid_station_m == null ? "" : String(data.site.mid_station_m / 1000);
|
||||
mid.input.dataset.b08Milestone = "mid_station_m";
|
||||
const road = createSelectField({
|
||||
label: "시점 → 중점 도로",
|
||||
@@ -277,40 +277,39 @@ export function openSiteInfo(projectId: string): void {
|
||||
label: "수정",
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
const text = mid.input.value;
|
||||
const value = Number(text);
|
||||
const km = text.trim() && Number.isFinite(value) && value >= 0 ? value : null;
|
||||
const fresh = liveMilestone(projectId);
|
||||
const value = Number(mid.input.value);
|
||||
fresh.site.mid_station_m = mid.input.value.trim() && value >= 0 ? value : null;
|
||||
fresh.site.mid_road = road.select.value as Road;
|
||||
putMilestone(projectId, fresh);
|
||||
putMilestone(
|
||||
projectId,
|
||||
setLeg(fresh, "start", 0, { road: road.select.value as Road, km }),
|
||||
);
|
||||
close();
|
||||
},
|
||||
});
|
||||
save.dataset.b08MilestoneSite = "save";
|
||||
actions.append(save);
|
||||
const map = buildAddressMap();
|
||||
const info = el("div", { children: [text("주소", "불러오는 중…")] });
|
||||
body.append(
|
||||
line(mid.root, road.root),
|
||||
text(
|
||||
"시점 = 노선 첫 점(측점 0)",
|
||||
`노선 ${km(site?.length_m)} km · 중점 기본 = 노선 ÷ 2(${km(site?.mid_default_m)} km) · 빈칸 = 기본`,
|
||||
),
|
||||
info,
|
||||
map.root,
|
||||
);
|
||||
request<SiteAddress>(`/projects/${projectId}/quantity/milestone/site-address`).then(
|
||||
(s) => {
|
||||
const ll = (p: [number, number] | null): string =>
|
||||
p ? ` (${p[1].toFixed(6)}, ${p[0].toFixed(6)})` : "";
|
||||
info.replaceChildren(
|
||||
text("프로젝트 등록 주소(사업지역)", s.region || "없음"),
|
||||
text("시점 브이월드 주소", (s.start_address ?? "못 찾음") + ll(s.start_lonlat)),
|
||||
text("중점 브이월드 주소", (s.mid_address ?? "못 찾음") + ll(s.mid_lonlat)),
|
||||
);
|
||||
if (s.start_lonlat) map.showPoint(s.start_lonlat[1], s.start_lonlat[0]);
|
||||
else if (s.region) void map.show(s.region);
|
||||
},
|
||||
(error: unknown) => showToast(`공사지 주소를 못 읽었습니다 — ${String(error)}`, "error"),
|
||||
const map = editMap(projectId, { hint: "", onMove: () => undefined });
|
||||
const pins = (s: {
|
||||
start_lonlat: [number, number] | null;
|
||||
mid_lonlat: [number, number] | null;
|
||||
}): MapPin[] =>
|
||||
(
|
||||
[
|
||||
["start", "시점", s.start_lonlat],
|
||||
["mid", "중점", s.mid_lonlat],
|
||||
] as const
|
||||
).flatMap(([key, name, p]) =>
|
||||
p ? [{ key, name, lon: p[0], lat: p[1], movable: false }] : [],
|
||||
);
|
||||
if (site) map.set(pins(site), []);
|
||||
body.append(line(mid.root, road.root), map.root);
|
||||
// 지금 중점 측점(캐시)의 중점 좌표 — 오면 표지 다시
|
||||
const query = data.site.mid_station_m == null ? "" : `?mid_m=${data.site.mid_station_m}`;
|
||||
request<SiteAddress>(`/projects/${projectId}/quantity/milestone/site-address${query}`).then(
|
||||
(s) => map.set(pins(s), []),
|
||||
(error: unknown) => showToast(`공사지 좌표를 못 읽었습니다 — ${String(error)}`, "error"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
*
|
||||
* [그림 변경] = 사용자 그림 올림(이 탭 = 그 그림) · [초기화] = 사용자 그림만 지움(길찾기 지도로 · 즉시 서버)
|
||||
* 서버 자동 그림(244-11 · 선 이을 때 · 서류용)은 그대로 만듦 · 이 탭에는 안 보임(157)
|
||||
* 255-2 시점도 끌기 = 길찾기용 덮개(`site.start_pin`) · 표지 옮기면 그 자리에 닿는 구간 모두 다시 길찾기 ·
|
||||
* 열 때 선 없는 사슬 구간 모두 길찾기 · 실패 = 안내 한 줄 · 끌기는 그림 캡처 안 함
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -26,7 +28,8 @@ import {
|
||||
putMilestone,
|
||||
routeImageKey,
|
||||
segmentPath,
|
||||
siteInfo,
|
||||
startPoint,
|
||||
touchingSegments,
|
||||
uploadImage,
|
||||
} from "./B08_Quantity_UI_Milestone_State";
|
||||
|
||||
@@ -120,20 +123,19 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
|
||||
});
|
||||
reset.dataset.b08MilestoneRevert = "";
|
||||
|
||||
const start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null;
|
||||
const pins = (): MapPin[] => {
|
||||
const d = liveMilestone(projectId);
|
||||
const start = startPoint(d);
|
||||
const r = d.routes.find((x) => x.id === routeId);
|
||||
const out: MapPin[] = [];
|
||||
const add = (key: string, label: string, lon?: number | null, lat?: number | null): void => {
|
||||
if (lon != null && lat != null)
|
||||
out.push({ key, name: label, lon, lat, movable: key !== "start" });
|
||||
if (lon != null && lat != null) out.push({ key, name: label, lon, lat, movable: true }); // 255-2 시점도 끌기(길찾기 덮개)
|
||||
};
|
||||
if (!r) return out;
|
||||
add(r.id, r.name || "거래처", r.lon, r.lat);
|
||||
if (r.linked === false) return out;
|
||||
for (const b of chainOf(d, r.to)) add(b.id, b.name || "기준점", b.lon, b.lat); // 254 기준점 사슬
|
||||
add("start", "시점", start()?.[0], start()?.[1]);
|
||||
add("start", "시점", start?.[0], start?.[1]);
|
||||
return out;
|
||||
};
|
||||
const lines = (): Array<Array<[number, number]>> => {
|
||||
@@ -142,24 +144,45 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
|
||||
const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : [];
|
||||
return [routeId, ...chain].map(segmentPath).filter((p) => p.length >= 2);
|
||||
};
|
||||
// 255-2 길찾기 실패 = 안내 한 줄(까닭 줄 = 「 — 」)
|
||||
const status = el("p", { className: "b08-options__hint" });
|
||||
status.dataset.b08MilestoneRouteStatus = "";
|
||||
status.hidden = true;
|
||||
/** 길 선 여럿 다시 길찾기(그림 캡처 없음 · 캡처는 [저장] 때) → 선 다시 · 실패 안내 */
|
||||
const refind = async (ids: string[]): Promise<void> => {
|
||||
const texts = await Promise.all(ids.map((id) => findSegmentKm(projectId, id, false)));
|
||||
const bad = texts.filter((t) => t.includes(" — "));
|
||||
status.textContent = bad.join(" · ");
|
||||
status.hidden = !bad.length;
|
||||
drawMap();
|
||||
};
|
||||
/** 표지 끌기 — 시점 = 덮개(site.start_pin · 노선 그대로) · 거래처 · 기준점 = 소재지 · 닿는 선 모두 다시 */
|
||||
const moveTo = async (key: string, lon: number, lat: number): Promise<void> => {
|
||||
const address = key === "start" ? "" : await reverseAddress(projectId, lon, lat);
|
||||
const fresh = liveMilestone(projectId);
|
||||
if (key === "start") fresh.site.start_pin = { lon, lat };
|
||||
else {
|
||||
const target = ownerOf(fresh, key);
|
||||
if (!target) return;
|
||||
Object.assign(target, { lon, lat, address });
|
||||
}
|
||||
putMilestone(projectId, fresh);
|
||||
await refind(touchingSegments(fresh, key));
|
||||
};
|
||||
const map = editMap(projectId, {
|
||||
hint: "",
|
||||
onMove: (key, lon, lat) =>
|
||||
void reverseAddress(projectId, lon, lat).then((addr) => {
|
||||
const fresh = liveMilestone(projectId);
|
||||
const target = ownerOf(fresh, key);
|
||||
if (!target) return;
|
||||
Object.assign(target, { lon, lat, address: addr });
|
||||
putMilestone(projectId, fresh);
|
||||
void findSegmentKm(projectId, key).then(() => drawMap());
|
||||
}),
|
||||
onMove: (key, lon, lat) => void moveTo(key, lon, lat),
|
||||
});
|
||||
const drawMap = (): void => map.set(pins(), lines());
|
||||
let asked = false; // 길 선이 아직 없으면 열 때 한 번 길찾기(그림 · 알림 없음)
|
||||
let asked = false; // 길 선이 없으면 열 때 한 번 길찾기(사슬 선 모두 · 그림 · 알림 없음)
|
||||
const ensurePath = (): void => {
|
||||
if (asked || segmentPath(routeId).length) return;
|
||||
if (asked) return;
|
||||
asked = true;
|
||||
void findSegmentKm(projectId, routeId, false).then(() => drawMap());
|
||||
const d = liveMilestone(projectId);
|
||||
const r = d.routes.find((x) => x.id === routeId);
|
||||
const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : [];
|
||||
const missing = [routeId, ...chain].filter((id) => !segmentPath(id).length);
|
||||
if (missing.length) void refind(missing);
|
||||
};
|
||||
|
||||
const root = el("section", {
|
||||
@@ -173,6 +196,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
|
||||
el("div", { className: "b08-milestone__bar", children: [change, reset, file] }),
|
||||
],
|
||||
}),
|
||||
status,
|
||||
img,
|
||||
map.root,
|
||||
],
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
* (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김
|
||||
* 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 · 기준점 → to(기준점 · 시점) · 254) ·
|
||||
* 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점)
|
||||
* 장소 그림(256-1) = 상자 좌표가 정해지면 `capturePlace` → 서버 캡처 `place/<상자 id>.png` ·
|
||||
* [저장] 뒤엔 서버가 바뀐 상자만 다시(다음 읽기에 보임) · `placeImageKey` · `placeImageBlobUrl`
|
||||
* 기준점 여럿(254) = `bases[]` · 길 선 표 키 = 거래처 · 기준점 id · 옛 꼴 캐시 = 읽을 때 새 꼴(`migrateMilestone`)
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -22,6 +24,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { request } from "./B08_Quantity_UI_Hydro";
|
||||
import {
|
||||
PAVED,
|
||||
SITE_ID,
|
||||
migrateMilestone,
|
||||
type MilestoneData,
|
||||
type SegKind,
|
||||
@@ -41,6 +44,8 @@ interface Payload {
|
||||
site: SiteInfo;
|
||||
images: Record<string, string>;
|
||||
auto_images: Record<string, string>;
|
||||
place_images: Record<string, string>;
|
||||
place_times: Record<string, number>;
|
||||
}
|
||||
|
||||
export const EMPTY: MilestoneData = {
|
||||
@@ -67,6 +72,19 @@ export async function loadMilestone(projectId: string, force = false): Promise<P
|
||||
|
||||
export const siteInfo = (): SiteInfo | null => saved?.site ?? null;
|
||||
|
||||
/** 길찾기 시점 = 경로 탭에서 끌어 놓은 덮개(255-2 · site.start_pin) · 없으면 노선 첫 점 */
|
||||
export const startPoint = (data: MilestoneData): [number, number] | null => {
|
||||
const pin = data.site.start_pin;
|
||||
return pin ? [pin.lon, pin.lat] : (siteInfo()?.start_lonlat ?? null);
|
||||
};
|
||||
|
||||
/** 그 자리(시점 "start" · 기준점 · 거래처 id)에 닿는 구간(255-2) — 나가는 구간(자기) + 들어오는 구간(to = 그 자리) */
|
||||
export function touchingSegments(data: MilestoneData, key: string): string[] {
|
||||
const to = key === "start" ? SITE_ID : key;
|
||||
const incoming = [...data.bases, ...data.routes].filter((o) => o.to === to && o.linked !== false);
|
||||
return [...(key === "start" ? [] : [key]), ...incoming.map((o) => o.id)];
|
||||
}
|
||||
|
||||
/** 시점 · 중점 주소(244-12 · 다이어그램 상자 작은 글씨) — 중점 측점마다 한 번 서버 VWorld(뒤에서 ·
|
||||
* 오면 다시 그림) · 아직 · 실패 = null(좌표만) */
|
||||
const siteAddresses = new Map<string, SiteAddress | null>();
|
||||
@@ -188,10 +206,11 @@ export async function autoRouteImage(
|
||||
lines: Array<Array<[number, number]>>,
|
||||
): Promise<void> {
|
||||
// 표지 = 거래처 쪽 구간 두 끝(mapMarks 차례 = 거래처 · 기준점(거침) · 시점 → 앞 둘)
|
||||
const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null).slice(
|
||||
0,
|
||||
2,
|
||||
);
|
||||
const marks = mapMarks(
|
||||
liveMilestone(projectId),
|
||||
routeId,
|
||||
startPoint(liveMilestone(projectId)),
|
||||
).slice(0, 2);
|
||||
let reason: string | null;
|
||||
try {
|
||||
const res = await request<{ name: string | null; reason: string | null }>(
|
||||
@@ -212,6 +231,43 @@ export async function autoRouteImage(
|
||||
changes.dispatchEvent(new Event("change"));
|
||||
}
|
||||
|
||||
/** 장소 그림(256-1) 다시 읽을 표 — 이름 + 바뀐 시각 · 없으면 null */
|
||||
export const placeImageKey = (placeId: string): string | null => {
|
||||
const name = saved?.place_images?.[placeId];
|
||||
return name ? `${name}#${saved?.place_times?.[placeId] ?? 0}` : null;
|
||||
};
|
||||
|
||||
/** 장소 그림(256-1) — 상자 좌표 · 이름 → 서버 캡처 → 목록 고쳐 알림(뒤에서 · 실패 = 알림 한 줄) */
|
||||
export async function capturePlace(
|
||||
projectId: string,
|
||||
placeId: string,
|
||||
mark: { name: string; lon: number; lat: number },
|
||||
): Promise<void> {
|
||||
let reason: string | null;
|
||||
try {
|
||||
const res = await request<{ name: string | null; time: number | null; reason: string | null }>(
|
||||
`/projects/${projectId}/quantity/milestone/place-image?id=${encodeURIComponent(placeId)}`,
|
||||
{ method: "POST", body: JSON.stringify(mark) },
|
||||
);
|
||||
reason = res.reason;
|
||||
if (res.name && saved) {
|
||||
saved.place_images = { ...saved.place_images, [placeId]: res.name };
|
||||
saved.place_times = { ...saved.place_times, [placeId]: res.time ?? Date.now() };
|
||||
}
|
||||
} catch (error) {
|
||||
reason = `장소 그림 캡처 요청 실패(${String(error)})`;
|
||||
}
|
||||
if (reason) showToast(reason, "warning");
|
||||
changes.dispatchEvent(new Event("change"));
|
||||
}
|
||||
|
||||
/** 장소 그림 → 화면 주소(blob · 쿠키 포함) */
|
||||
export async function placeImageBlobUrl(projectId: string, placeId: string): Promise<string> {
|
||||
const name = saved?.place_images?.[placeId] ?? `place/${placeId}.png`;
|
||||
const url = `${API_BASE_URL}/projects/${projectId}/quantity/milestone/file?name=${encodeURIComponent(name)}`;
|
||||
return URL.createObjectURL(await (await call(url)).blob());
|
||||
}
|
||||
|
||||
/** 그림 → 화면 주소(blob · 쿠키 포함) */
|
||||
export async function imageBlobUrl(projectId: string, routeId: string): Promise<string> {
|
||||
return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob());
|
||||
@@ -239,7 +295,7 @@ export async function findSegmentKm(
|
||||
const data = liveMilestone(projectId);
|
||||
const owner = ownerOf(data, from);
|
||||
if (owner?.manual) return ""; // 155 수기 입력 = 카카오 길찾기 안 함
|
||||
const seg = segmentEnds(data, from, siteInfo()?.start_lonlat ?? null);
|
||||
const seg = segmentEnds(data, from, startPoint(data));
|
||||
if (!seg) return "";
|
||||
if (!seg.from || !seg.to) {
|
||||
const missing = seg.text.split(" → ")[seg.from ? 1 : 0];
|
||||
|
||||
@@ -106,9 +106,12 @@ def structure_items(summary: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def haul_items(
|
||||
materials: dict[str, Any] | None, refs: dict[str, Any] | None = None
|
||||
materials: dict[str, Any] | None,
|
||||
refs: dict[str, Any] | None = None,
|
||||
routes: list[dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""운반공 줄 — B08 운반 묶음(251-1 · 장비 둘 + 총괄자재 묶음 · 옛 키 haul:1~6 그대로) ·
|
||||
"""운반공 줄 — B08 운반 묶음(251-1 · 장비 둘 + 총괄자재 묶음 · 옛 키 haul:1~6 그대로) +
|
||||
거리이정표 사용자 상자 행(253-1 · `B08_Quantity_Haul.haul_rows` · 수량 빈칸) ·
|
||||
연결 = 거리이정표 단가연결(251-5 · `refs{줄 키: ref}` · 설계내역서 연결 덮개가 이김)."""
|
||||
out = []
|
||||
for g in haul_groups(materials):
|
||||
@@ -128,6 +131,24 @@ def haul_items(
|
||||
"why": WHY_LOGIC if logic else WHY_HAUL,
|
||||
}
|
||||
)
|
||||
for r in routes or []:
|
||||
if not r.get("user"):
|
||||
continue
|
||||
ref = (refs or {}).get(r["key"])
|
||||
logic = (ref or {}).get("tab") == "logic"
|
||||
out.append(
|
||||
{
|
||||
"key": r["key"],
|
||||
"section": "3",
|
||||
"name": r["name"],
|
||||
"spec": "",
|
||||
"unit": "",
|
||||
"qty": None,
|
||||
"note": "B08 거리이정표",
|
||||
"ref": ref,
|
||||
"why": WHY_LOGIC if logic else WHY_HAUL,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -136,7 +157,7 @@ def bill_items(b08: dict[str, Any]) -> dict[str, Any]:
|
||||
"items": [
|
||||
*earth_items(b08.get("earthwork")),
|
||||
*structure_items(b08.get("summary")),
|
||||
*haul_items(b08.get("materials"), b08.get("haul_refs")),
|
||||
*haul_items(b08.get("materials"), b08.get("haul_refs"), b08.get("haul_routes")),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -490,11 +490,10 @@ def compute_bill(
|
||||
gaps.extend(row.pop("_gaps", []))
|
||||
row.pop("_home", None)
|
||||
row["no"] = ""
|
||||
if row["kind"] == "title":
|
||||
empty = f" 빈 줄 {row['blank']}" if row["blank"] else ""
|
||||
row["note"] = ("(제외금액)" if row["key"] == "sec:G" else "") + empty
|
||||
if row["kind"] == "title": # 257-2 비고 「빈 줄 n」 없음(blank 수는 줄 값 그대로)
|
||||
row["note"] = "(제외금액)" if row["key"] == "sec:G" else ""
|
||||
elif row["kind"] == "group":
|
||||
row["note"] = f"빈 줄 {row['blank']}" if row["blank"] else ""
|
||||
row["note"] = ""
|
||||
if row["kind"] != "item" and row["cost"] == "direct" and row["level"] <= 2:
|
||||
up = row["parent"] # 번호 = 레벨 1 · 2 묶음 차례(1 · 1.1 · 관급 없음)
|
||||
seq[up] = seq.get(up, 0) + 1
|
||||
|
||||
@@ -458,10 +458,9 @@ export function computeBill(
|
||||
delete row._gaps;
|
||||
delete row._home;
|
||||
row.no = "";
|
||||
if (row.kind === "title")
|
||||
row.note =
|
||||
(row.key === "sec:G" ? "(제외금액)" : "") + (row.blank ? ` 빈 줄 ${row.blank}` : "");
|
||||
else if (row.kind === "group") row.note = row.blank ? `빈 줄 ${row.blank}` : "";
|
||||
// 257-2 비고 「빈 줄 n」 없음(blank 수는 줄 값 그대로)
|
||||
if (row.kind === "title") row.note = row.key === "sec:G" ? "(제외금액)" : "";
|
||||
else if (row.kind === "group") row.note = "";
|
||||
if (row.kind !== "item" && row.cost === "direct" && row.level <= 2) {
|
||||
// 번호 = 레벨 1 · 2 묶음 차례(1 · 1.1 · 관급 없음)
|
||||
const up = row.parent;
|
||||
|
||||
@@ -27,11 +27,13 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from B08_Quantity.B08_Quantity_Haul import bill_key
|
||||
from B08_Quantity.B08_Quantity_Haul import haul_rows
|
||||
from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone
|
||||
from B08_Quantity.B08_Quantity_Router_Earthwork import earthwork_table
|
||||
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||||
from B08_Quantity.B08_Quantity_Router_Material import material_sheets
|
||||
from B08_Quantity.B08_Quantity_Router_Milestone import read_data as read_milestone
|
||||
from B08_Quantity.B08_Quantity_Router_Milestone import site_info
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library
|
||||
from B09_Estimation import B09_Estimation_Prices as prices
|
||||
from B09_Estimation import B09_Estimation_Sangeun as sangeun
|
||||
@@ -110,18 +112,27 @@ def compute_twice(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> t
|
||||
return sheets, compute_summary(sheets, hopyo_lines(sheets))
|
||||
|
||||
|
||||
def haul_refs(root: Path) -> dict[str, Any]:
|
||||
"""거리이정표 운반 상자 단가연결(251-5) → `{설계내역서 줄 키: ref}` — 못 읽으면 빈 묶음."""
|
||||
def haul_routes(root: Path) -> list[dict[str, Any]]:
|
||||
"""거리이정표 행 → 운반공 줄(253-1 · `haul_rows` · 거리 L1 · L2 포함) — 못 읽으면 빈 목록."""
|
||||
try:
|
||||
routes = read_milestone(root).get("routes") or []
|
||||
except Exception: # noqa: BLE001 — 거리이정표 없음 = 연결 없음
|
||||
return {}
|
||||
return {bill_key(r["haul"]): r["ref"] for r in routes if r.get("haul") and r.get("ref")}
|
||||
data = read_milestone(root)
|
||||
mid = site_info(root, data["site"]["mid_station_m"])["mid_default_m"]
|
||||
return haul_rows(data, compute_milestone(data, mid))
|
||||
except Exception: # noqa: BLE001 — 거리이정표 없음 = 운반 연결 없음
|
||||
return []
|
||||
|
||||
|
||||
def haul_user(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""셈 덧값 — `haul_refs{줄 키: ref}`(251-5) · `haul_dist{줄 키: {L1, L2}}`(253-2 거리 채움)."""
|
||||
return {
|
||||
"haul_refs": {r["key"]: r["ref"] for r in rows if r.get("ref")},
|
||||
"haul_dist": {r["key"]: {"L1": r["L1"], "L2": r["L2"]} for r in rows},
|
||||
}
|
||||
|
||||
|
||||
def calc_user(root: Path) -> dict[str, Any]:
|
||||
"""셈 · 수요 · 사본 정리용 사용자 값 — 저장 값 + 거리이정표 운반 연결(화면엔 안 보냄)."""
|
||||
return {**store.read_user(root), "haul_refs": haul_refs(root)}
|
||||
"""셈 · 수요 · 사본 정리용 사용자 값 — 저장 값 + 거리이정표 운반 연결 · 거리(화면엔 안 보냄)."""
|
||||
return {**store.read_user(root), **haul_user(haul_routes(root))}
|
||||
|
||||
|
||||
async def _demand(root: Path) -> list[str]:
|
||||
@@ -143,11 +154,12 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
|
||||
)
|
||||
book = await asyncio.to_thread(store.read_book, root)
|
||||
inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"])
|
||||
inputs["bill"] = bill_items({**b08, "haul_refs": await asyncio.to_thread(haul_refs, root)})
|
||||
hauls = await asyncio.to_thread(haul_routes, root)
|
||||
inputs["bill"] = bill_items({**b08, **haul_user(hauls), "haul_routes": hauls})
|
||||
inputs["hopyo"] = hopyo_prices(root)
|
||||
inputs["daega"] = await asyncio.to_thread(daega_inputs, root)
|
||||
user = await asyncio.to_thread(store.read_user, root)
|
||||
calc = {**user, "haul_refs": await asyncio.to_thread(haul_refs, root)}
|
||||
calc = {**user, **haul_user(hauls)}
|
||||
sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, calc)
|
||||
if schedule_draft:
|
||||
schedule_draft.attach(inputs, sheets, user)
|
||||
@@ -176,7 +188,7 @@ async def estimation(project_id: UUID) -> dict[str, Any]:
|
||||
async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
user = await asyncio.to_thread(store.write_user, root, body.get("user"))
|
||||
calc = {**user, "haul_refs": await asyncio.to_thread(haul_refs, root)}
|
||||
calc = {**user, **haul_user(await asyncio.to_thread(haul_routes, root))}
|
||||
await asyncio.to_thread(library.prune, root, calc) # 204-2 줄이 안 잇는 서고 사본
|
||||
keys = await _demand(root)
|
||||
book = await asyncio.to_thread(store.read_book, root)
|
||||
|
||||
@@ -37,6 +37,7 @@ from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Haul_Cycle import fill_given
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, rounddown, total
|
||||
from B09_Estimation.B09_Estimation_Store import ref_rows
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
@@ -475,13 +476,27 @@ class Solver:
|
||||
LISTED = ("no", "key", "logic", "name", "spec", "spec_raw", "unit", "sum", *PARTS, "uses")
|
||||
|
||||
|
||||
def _input_names(code: str) -> list[str]:
|
||||
try:
|
||||
return [str(i.get("이름")) for i in m01.loaded()[1].logic(code).get("입력") or []]
|
||||
except mf.FormulaError:
|
||||
return []
|
||||
|
||||
|
||||
def hand_sources(user: dict[str, Any] | None) -> list[tuple[str, str, dict[str, Any]]]:
|
||||
"""설계내역서 손 줄 중 M01 로직 연결 → [(줄 키, 쓰는 곳 글, 산근 원천)]."""
|
||||
"""설계내역서 손 줄 중 M01 로직 연결 → [(줄 키, 쓰는 곳 글, 산근 원천)] ·
|
||||
운반공 줄(253-2 · `haul_dist{줄 키: {L1, L2}}`) = 거리이정표 거리로 로직 입력 다시 채움(정본)."""
|
||||
out = []
|
||||
dist = (user or {}).get("haul_dist") or {}
|
||||
for row in ref_rows(user): # 251-5 거리이정표 운반 연결 포함
|
||||
ref = row.get("ref") or {}
|
||||
if ref.get("tab") != "logic" or not ref.get("code"):
|
||||
continue
|
||||
if row["key"] in dist:
|
||||
ref = {
|
||||
**ref,
|
||||
"given": fill_given(ref, _input_names(str(ref["code"])), dist[row["key"]]),
|
||||
}
|
||||
src = {"code": str(ref["code"]), "given": _clean_given(ref.get("given")),
|
||||
"rate": ref.get("rate"), "picks": ref.get("picks")} # fmt: skip
|
||||
where = "수기 줄" if row["key"].startswith("hand:") else "내역 줄" # 184 자동 줄 연결 덮개
|
||||
|
||||
@@ -126,9 +126,15 @@ def _clean_ref(ref: Any) -> dict[str, Any] | None:
|
||||
if tab == "logic" and isinstance(ref.get("code"), str) and ref["code"]:
|
||||
given = ref.get("given") if isinstance(ref.get("given"), dict) else {}
|
||||
picks = ref.get("picks") if isinstance(ref.get("picks"), dict) else None
|
||||
return {"tab": "logic", "code": ref["code"], "name": _text(ref.get("name")),
|
||||
"given": {str(k): v for k, v in given.items() if isinstance(v, (str, int, float))},
|
||||
"rate": _num(ref.get("rate")), "picks": picks} # fmt: skip
|
||||
out = {"tab": "logic", "code": ref["code"], "name": _text(ref.get("name")),
|
||||
"given": {str(k): v for k, v in given.items() if isinstance(v, (str, int, float))},
|
||||
"rate": _num(ref.get("rate")), "picks": picks} # fmt: skip
|
||||
cycle = ref.get("cycle") # 253-2 운반 사이클 입력 · 덮은 칸
|
||||
if isinstance(cycle, dict):
|
||||
out["cycle"] = {str(k): _num(v) for k, v in cycle.items() if _num(v) is not None}
|
||||
own = ref.get("own") if isinstance(ref.get("own"), list) else []
|
||||
out["own"] = [str(x)[:40] for x in own if isinstance(x, str)][:40]
|
||||
return out
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈 */
|
||||
/* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈(257-1 머리 글 안 줄바꿈 글자도 살림) */
|
||||
[data-b09-table] thead th {
|
||||
white-space: normal;
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -136,6 +136,26 @@
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* 257-1 설계내역서 단가연결 칸 = 램프(B08 구조물집계표 램프 꼴 · 글 숨김 · 풍선 = 글) — 노랑 = 없음 · 초록 = 연결 */
|
||||
.b09-estimation .ui-data-table__table tbody td.b09-bill__lamp {
|
||||
font-size: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody td.b09-bill__lamp::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-warning);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody td.b09-bill__lamp.is-linked::before {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
/* 184-3 설계내역서 고친 칸 — 자동 값과 다른 칸(풍선 = 자동 값) */
|
||||
.b09-estimation .ui-data-table__table tbody td.is-edited {
|
||||
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
|
||||
|
||||
@@ -62,7 +62,13 @@ import {
|
||||
} from "./B09_Estimation_UI_Tabs_Summary";
|
||||
import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total";
|
||||
import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun";
|
||||
import { isGroupTab, lineHint, pickClass, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups";
|
||||
import {
|
||||
isGroupTab,
|
||||
lineHint,
|
||||
pickClass,
|
||||
renderGroupPanel,
|
||||
unpickOnRelease,
|
||||
} from "./B09_Estimation_UI_Tabs_Groups";
|
||||
|
||||
// 252 초안 탭(공기 등) — draft 폴더가 있으면 등록 · 지우면 빈 목록(오류 없음)
|
||||
import.meta.glob("./draft/*_UI.ts", { eager: true });
|
||||
@@ -274,13 +280,17 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
const bill = (spec as BillTable).menu ? (spec as BillTable) : null;
|
||||
const at = focus === undefined ? -1 : rowIndex(spec.rows, focus);
|
||||
if (bill) jumpTo(bill.rows, at);
|
||||
// 257-2 설계내역서 밖 탭도 이동해 온 줄 = 같은 빨강 상자(그 줄 하나 · 줄 누름 · Esc 로 풂)
|
||||
const range: [number, number] | null = !bill && at >= 0 ? [at, at] : null;
|
||||
const rangeClass = range ? (i: number) => pickClass(range, i) : undefined;
|
||||
const table = makeTable(
|
||||
bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i) : undefined,
|
||||
bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i) : rangeClass,
|
||||
);
|
||||
if (range) unpickOnRelease(table.root);
|
||||
const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null;
|
||||
if (level) bars.setTools(level.tools);
|
||||
panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : []));
|
||||
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, !bill)); // 설계내역서 = 스크롤만(표시 = 빨강)
|
||||
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강)
|
||||
};
|
||||
|
||||
const apply = (next: Row, fromServer: boolean): void => {
|
||||
|
||||
@@ -59,8 +59,7 @@ export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1]
|
||||
/** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */
|
||||
export const TAB_GROUPS: Array<[string, TabId[]]> = [
|
||||
["공사원가계산서", ["cost"]],
|
||||
["총괄설계내역서", ["total"]],
|
||||
["설계내역서", ["bill"]],
|
||||
["설계내역", ["total", "bill"]],
|
||||
["일위대가", ["dg_list", "dg"]],
|
||||
["단가산출", ["sg_list", "sg"]],
|
||||
["환율및기초자료", ["basis"]],
|
||||
|
||||
@@ -34,10 +34,12 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
const bill: Row = sheets.bill || { rows: [], totals: null };
|
||||
// 손질 · 메뉴 = 셈 줄 그대로(raw) · 표 = 번호 붙인 이름(no = 레벨 1 · 2 묶음 차례)
|
||||
const raw: DataRow[] = bill.rows;
|
||||
// 257-1 연결 칸 = 램프(글은 CSS 가 숨김 · 풍선 = 글) — 연결 = 호표 줄(ref) 또는 손 줄 비고(대가N)
|
||||
const linkText = (r: Row): string => (!r.auto && r.note ? r.note : r.ref ? "연결됨" : "단가연결");
|
||||
const rows: DataRow[] = raw.map((r: Row) => ({
|
||||
...r,
|
||||
name: r.no ? `${r.no} ${r.name}` : r.name,
|
||||
connect: linkable(r) ? `${!r.auto && r.note ? r.note : r.ref ? "연결됨" : "단가연결"} ▾` : "",
|
||||
connect: linkable(r) ? `${linkText(r)} ▾` : "",
|
||||
}));
|
||||
const direct: Row = bill.totals?.direct || {};
|
||||
const pair = (key: string, label: string) => [
|
||||
@@ -57,7 +59,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
...pair("labor", "노무비"),
|
||||
...pair("material", "재료비"),
|
||||
...pair("expense", "경비"),
|
||||
text("connect", "단가연결", 96),
|
||||
{ ...text("connect", "단가\n연결", 44), title: "단가연결" },
|
||||
text("note", "비고", 200),
|
||||
],
|
||||
rows,
|
||||
@@ -88,9 +90,21 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
if (r.key !== "fee") return editCell(ctx, raw, i, key, v);
|
||||
setUser({ ...user, estimate: { ...(user.estimate || {}), procurement_fee_pct: v } });
|
||||
},
|
||||
cellClass: (i, key) => (edited(rows[i], key) ? "is-edited" : null),
|
||||
cellClass: (i, key) => {
|
||||
const r = rows[i];
|
||||
const lamp =
|
||||
key === "connect" && r && linkable(raw[i])
|
||||
? `b09-bill__lamp${linkText(raw[i]) === "단가연결" ? "" : " is-linked"}`
|
||||
: "";
|
||||
return [lamp, edited(r, key) ? "is-edited" : ""].filter(Boolean).join(" ") || null;
|
||||
},
|
||||
tip: (i, key) => {
|
||||
const r = rows[i];
|
||||
if (r && key === "connect" && linkable(raw[i])) {
|
||||
const auto = (r.auto_ref as Row | null)?.key ?? "빈칸";
|
||||
const back = edited(r, key) ? ` — 자동 값 ${auto} — 우클릭 [자동값으로 되돌리기]` : "";
|
||||
return linkText(raw[i]) + back;
|
||||
}
|
||||
if (r && r.kind !== "item" && SUM_KEYS.includes(key)) return "수식 = 아래 항목 합(읽기 전용)";
|
||||
if (!r || !edited(r, key)) return null;
|
||||
const auto = key === "connect" ? (r.auto_ref as Row | null)?.key : r[`auto_${key}`];
|
||||
|
||||
@@ -203,6 +203,17 @@ function listenKeys(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** 257-2 이동해 온 줄 빨강(묶음 아닌 표 · 설계내역서 빼고) — 줄 누름 · 방향키 · Esc 로 풂(다시 그리지 않음) */
|
||||
export function unpickOnRelease(root: HTMLElement): void {
|
||||
const clear = (): void => {
|
||||
for (const tr of root.querySelectorAll("tr.is-pick"))
|
||||
tr.classList.remove("is-pick", "is-pick-top", "is-pick-end");
|
||||
};
|
||||
root.addEventListener("click", clear);
|
||||
live = { root, unpick: clear };
|
||||
listenKeys();
|
||||
}
|
||||
|
||||
/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */
|
||||
const chosen = new Map<TabId, GroupView>();
|
||||
|
||||
|
||||
@@ -223,8 +223,9 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
|
||||
`순자재대 ${won(supply.net ?? null)} + 조달수수료 ${won(supply.fee ?? null)} → 천원 올림`,
|
||||
{ hand: hand("supply") },
|
||||
),
|
||||
row("grand", "바.", "총공사비", sub.grand ?? null),
|
||||
];
|
||||
// 257-2 총공사비 = 표 아래 고정 줄(템플릿 total · 몸 줄 아님 · 줄 이동 누름 없음)
|
||||
const grand = row("grand", "바.", "총공사비", sub.grand ?? null);
|
||||
const safetyRow = rows.find((r) => r.key === "safety");
|
||||
if (safetyRow && safety.a !== null && safety.a !== undefined)
|
||||
safetyRow.note = `A(관급 ÷ 1.1 포함) ${won(safety.a)} · B(× 1.2) ${won(safety.b)} → 작은 쪽`;
|
||||
@@ -245,6 +246,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
|
||||
text("candidate", "요율 출처", 260),
|
||||
],
|
||||
rows,
|
||||
total: grand,
|
||||
frozen: 2,
|
||||
rowClass: (i) => levelClass(rows[i]),
|
||||
level: nameLevel(rows),
|
||||
@@ -288,8 +290,7 @@ const COST_ROWS: Array<[string, string, string, string, string?]> = [
|
||||
["vat", "부가가치세", "", "", "vat"],
|
||||
["contract", "도급공사비", "", "", "contract"],
|
||||
["supply", "관급자재대", "", "", "supply"],
|
||||
["grand", "총공사비", "", "", "grand"],
|
||||
];
|
||||
]; // 총공사비(grand) = 표 아래 고정 줄(257-2 · buildCost total)
|
||||
|
||||
export const buildCost: TabBuild = (ctx): TabTable => {
|
||||
const t: Row = ctx.sheets.total || {};
|
||||
@@ -314,6 +315,14 @@ export const buildCost: TabBuild = (ctx): TabTable => {
|
||||
level: !part ? 1 : name === "소계" ? 2 : 3,
|
||||
}));
|
||||
return {
|
||||
// 257-2 총공사비 = 표 아래 고정 줄(템플릿 total · 줄 이동 누름 없음)
|
||||
total: {
|
||||
key: "grand",
|
||||
group: "총공사비",
|
||||
amount: cost.grand ?? null,
|
||||
share: share.grand ?? null,
|
||||
note: formula.grand ?? "",
|
||||
},
|
||||
columns: [
|
||||
text("group", "구분", 96),
|
||||
text("part", "비목", 72),
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/* PLAN 259 단위검증 헬퍼 — [상세] 공용 팬 누름 풂 · 암 경계선 나눔 · 닫힌 영역 · 표 칸 → 그래프 고름.
|
||||
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 표준입력 JSON {design, samples, axes, rock} ·
|
||||
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_259_detail_region.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const B06 = path.join(ROOT, "B06_Section");
|
||||
const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8");
|
||||
const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)();
|
||||
globalThis.window = { sessionStorage: new Map(), localStorage: new Map() };
|
||||
globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} };
|
||||
|
||||
const STUBS = {
|
||||
"@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) },
|
||||
"./B06_Section_UI_Section_Common": { CROSS_PAD },
|
||||
};
|
||||
const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
|
||||
function loadTs(full) {
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.endsWith(".css")) return {};
|
||||
if (request.startsWith("@util/"))
|
||||
return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6))));
|
||||
return loadTs(resolve(path.join(path.dirname(full), request)));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
const Z = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts"));
|
||||
const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts"));
|
||||
const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts"));
|
||||
const T = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Table.ts"));
|
||||
|
||||
const { design, samples, axes } = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = {};
|
||||
|
||||
/* ① 공용 팬 — 가운데 단추 팬(click 없음) 뒤 왼쪽 누름 click 이 안 삼켜짐 */
|
||||
function panCase(panStart) {
|
||||
const listeners = {};
|
||||
const svg = {
|
||||
addEventListener: (name, fn) => (listeners[name] ??= []).push(fn),
|
||||
setAttribute: () => {},
|
||||
classList: { add: () => {}, remove: () => {} },
|
||||
setPointerCapture: () => {},
|
||||
releasePointerCapture: () => {},
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 600, height: 300 }),
|
||||
};
|
||||
const layer = { setAttribute: () => {} };
|
||||
Z.attachSvgZoomPan({
|
||||
svg,
|
||||
layer,
|
||||
widthPx: 600,
|
||||
heightPx: 300,
|
||||
pad: { left: 10, right: 10, top: 10, bottom: 10 },
|
||||
content: { x: -600, y: -300, width: 1800, height: 900 },
|
||||
panStart,
|
||||
});
|
||||
const fire = (name, init = {}) => {
|
||||
let stopped = false;
|
||||
const event = {
|
||||
button: 0,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
pointerId: 1,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => (stopped = true),
|
||||
...init,
|
||||
};
|
||||
(listeners[name] ?? []).forEach((fn) => fn(event));
|
||||
return stopped;
|
||||
};
|
||||
const drag = (button) => {
|
||||
fire("pointerdown", { button, clientX: 100, clientY: 100 });
|
||||
fire("pointermove", { button, clientX: 140, clientY: 120 });
|
||||
fire("pointerup", { button, clientX: 140, clientY: 120 });
|
||||
};
|
||||
const click = (x = 50) => {
|
||||
fire("pointerdown", { clientX: x, clientY: 50 });
|
||||
fire("pointerup", { clientX: x, clientY: 50 });
|
||||
return fire("click", { clientX: x, clientY: 50 });
|
||||
};
|
||||
return { drag, click, fire };
|
||||
}
|
||||
{
|
||||
const middle = panCase();
|
||||
middle.drag(1); // 가운데 단추 팬 — 브라우저는 click 을 안 보냄(auxclick)
|
||||
out.panMiddle = { firstClickStopped: middle.click(), secondClickStopped: middle.click() };
|
||||
// 거리이정표 꼴 — 빈 곳 왼쪽 끌기 팬(끌기로 끝난 click 은 막고 · 다음 누름은 통과)
|
||||
const left = panCase((event) => event.button === 1 || event.button === 0);
|
||||
left.drag(0);
|
||||
// 끌기 끝 click(왼쪽은 브라우저가 보냄) — 막혀야 함
|
||||
out.panLeft = { afterDragStopped: left.fire("click"), nextClickStopped: left.click() };
|
||||
}
|
||||
|
||||
/* ② 암 경계선 — 그림 요소 → 조각(설계선과 만나는 점에서 나눔) */
|
||||
const px = ([offset, elevation]) =>
|
||||
[
|
||||
CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter,
|
||||
CROSS_PAD.top +
|
||||
(axes.displayMax -
|
||||
(axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) *
|
||||
axes.pixelsPerMeter,
|
||||
].join(",");
|
||||
const el = (classes, points, attrs = {}) => ({
|
||||
tagName: "polyline",
|
||||
classList: { contains: (name) => classes.includes(name) },
|
||||
hasAttribute: (name) => name in attrs,
|
||||
getAttribute: (name) => (name === "points" ? points.map(px).join(" ") : (attrs[name] ?? null)),
|
||||
});
|
||||
const designPts = design.design_line.map((p) => [p.offset_m, p.elevation_m]);
|
||||
const groundPts = samples.filter((s) => s.valid !== false).map((s) => [s.offset_m, s.elevation_m]);
|
||||
const rockPts = G.rockRuns(design, samples)[0];
|
||||
const elements = [
|
||||
el(["b06-chart__design-cross"], designPts, { "data-design-line": "" }),
|
||||
el(["b06-chart__cross-profile"], groundPts),
|
||||
el(["b06-chart__rock-boundary"], rockPts),
|
||||
];
|
||||
const pieces = S.collectPieces(elements, axes);
|
||||
out.pieces = pieces;
|
||||
const rockPieces = pieces.filter((p) => p.line === "rock");
|
||||
out.rockLabel = S.selectionLabel(design, [rockPieces[1]]);
|
||||
out.rockSource = S.selectionSource("length", [rockPieces[1]]);
|
||||
out.rockLocal = T.localCellValue(out.rockSource, design, samples);
|
||||
out.rockLength = G.pathLength(rockPieces[1].points);
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -124,7 +124,7 @@ def test_제목_합_빈_줄과_원가계산서_이음():
|
||||
assert keys[:3] == ["sec:1", "sec:1.1", "sec:1.2"] and keys[-1] == "sec:G"
|
||||
assert rows["sec:1.1"]["sum"] == 361494 + 1119272 and rows["sec:1.1"]["blank"] == 0
|
||||
assert rows["sec:1"]["sum"] == 361494 + 1119272 + 11003825 # 빈 줄은 0 이 아니라 빈 줄 수
|
||||
assert rows["sec:1"]["blank"] == 1 and "빈 줄 1" in rows["sec:1"]["note"]
|
||||
assert rows["sec:1"]["blank"] == 1 and "빈 줄" not in rows["sec:1"]["note"] # 257-2 비고 빈 줄 없음
|
||||
# 145-4 이음 = {material, labor, expense, supply_net} · 빈 줄 있으면 None
|
||||
assert sheets["bill"]["sums"] == {
|
||||
"material": None,
|
||||
|
||||
@@ -191,7 +191,7 @@ t = buildTotal(ctx());
|
||||
t.onEdit(at(t.rows, "care"), "rate", "");
|
||||
out.careGone = "care" in user.estimate.rates;
|
||||
t = buildTotal(ctx());
|
||||
out.grandBlank = t.rows[at(t.rows, "grand")].amount;
|
||||
out.grandBlank = t.total.amount; // 257-2 총공사비 = 표 아래 고정 줄
|
||||
t.onEdit(at(t.rows, "safety"), "hand", "1,000");
|
||||
out.base = user.estimate.safety_base;
|
||||
const c = buildCost(ctx());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 177-1 — 구조물 조합 미연결 라벨 = 연한 노랑(경고) 배경 + 테두리(소스 시험)."""
|
||||
"""PLAN 177-1 → 258-3 — 구조물 조합 미연결 라벨 = 옅은 회색(구조물 리스트 램프 색) 배경 + 테두리(소스 시험)."""
|
||||
|
||||
import os
|
||||
import re
|
||||
@@ -15,10 +15,10 @@ def _rule(sel: str) -> str:
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_미연결_배경_테두리_경고색_글자색_없음():
|
||||
def test_미연결_배경_테두리_회색_글자색_없음():
|
||||
body = _rule(".b05-structure__summary-name:not(.is-assigned)")
|
||||
assert re.search(r"border-color:\s*var\(--color-warning\)", body)
|
||||
assert re.search(r"background:\s*color-mix\([^;]*--color-warning[^;]*--color-surface", body)
|
||||
assert re.search(r"border-color:\s*var\(--color-text-muted\)", body)
|
||||
assert re.search(r"background:\s*color-mix\([^;]*--color-text-muted[^;]*--color-surface", body)
|
||||
assert not re.search(r"(^|[;\s])color:", body)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""181-1 B09 탭 두 층 — 위 탭 9(203-1 연결 점검 뺌) · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭.
|
||||
"""181-1 B09 탭 두 층 — 위 탭 8(203-1 연결 점검 뺌 · 257-1 설계내역 한 묶음) · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭.
|
||||
|
||||
- 위 탭 = `TAB_GROUPS`(`TABS` 18 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로
|
||||
- 줄 = 위 md · 아래 sm · 아래 탭 없는 위 탭 = 빈 보조 줄(B08 꼴)
|
||||
@@ -19,8 +19,7 @@ BAR = "B09_Estimation/B09_Estimation_UI_TabBar.ts"
|
||||
# 사용자 30번 — 위 탭 차례 = 지금 탭 차례(묶음 첫 자리) · 아래 = 글에 적힌 차례
|
||||
EXPECTED = [
|
||||
["공사원가계산서", ["cost"]],
|
||||
["총괄설계내역서", ["total"]],
|
||||
["설계내역서", ["bill"]],
|
||||
["설계내역", ["total", "bill"]],
|
||||
["일위대가", ["dg_list", "dg"]],
|
||||
["단가산출", ["sg_list", "sg"]],
|
||||
["환율및기초자료", ["basis"]],
|
||||
@@ -42,7 +41,7 @@ def test_묶음_표_사용자_30번과_같고_18_탭이_한_번씩():
|
||||
# 위 탭 차례 = 엑셀 차례에서 묶음이 처음 나오는 자리
|
||||
firsts = list(dict.fromkeys(out["of"]))
|
||||
assert firsts == [g[0] for g in EXPECTED]
|
||||
assert len(firsts) == 9
|
||||
assert len(firsts) == 8
|
||||
|
||||
|
||||
def test_줄_그리기_두_층_빈_줄_마지막_본_아래_탭():
|
||||
@@ -60,26 +59,26 @@ const view = () => ({
|
||||
});
|
||||
bars.draw('dg');
|
||||
const a = view();
|
||||
bars.top.children[3].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음
|
||||
bars.top.children[2].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음
|
||||
bars.sub.children[0].click(); // 일위대가목록표
|
||||
bars.draw('compare'); // 묶음을 넘는 이동 = 위 탭 따라 켜짐
|
||||
const b = view();
|
||||
bars.draw('sum_material');
|
||||
bars.draw('dg');
|
||||
bars.top.children[7].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표)
|
||||
bars.top.children[6].click(); // 중기 = 본 적 없음 → 첫 아래 탭
|
||||
bars.top.children[6].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표)
|
||||
bars.top.children[5].click(); // 중기 = 본 적 없음 → 첫 아래 탭
|
||||
bars.draw('cost');
|
||||
const c = view();
|
||||
bars.top.children[1].click(); // 총괄설계내역서 = 단독 위 탭
|
||||
bars.top.children[1].click(); // 설계내역 = 첫 아래 탭(총괄)
|
||||
bars.sub.children[0].click(); // 빈 줄 단추(숨김) = 누름 없음
|
||||
console.log(JSON.stringify({ a, b, c, picks }));
|
||||
""",
|
||||
)
|
||||
a, b, c = out["a"], out["b"], out["c"]
|
||||
assert len(a["top"]) == 9 and [t[0] for t in a["top"]][:5] == [
|
||||
"공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가", "단가산출"
|
||||
assert len(a["top"]) == 8 and [t[0] for t in a["top"]][:5] == [
|
||||
"공사원가계산서", "설계내역", "일위대가", "단가산출", "환율및기초자료"
|
||||
] # fmt: skip
|
||||
assert [t[2] for t in a["top"]] == ["false"] * 3 + ["true"] + ["false"] * 5
|
||||
assert [t[2] for t in a["top"]] == ["false"] * 2 + ["true"] + ["false"] * 5
|
||||
assert all("ui-btn--ghost" in t[1] for t in a["top"] if t[2] == "false")
|
||||
assert a["sub"] == [["일위대가목록표", "false", "dg_list"], ["일위대가표", "true", "dg"]]
|
||||
assert not a["blank"] and a["role"] == "tablist"
|
||||
@@ -88,7 +87,7 @@ console.log(JSON.stringify({ a, b, c, picks }));
|
||||
["자재단가대비표", "true", "compare"],
|
||||
["재료비수량금액집계표", "false", "sum_material"],
|
||||
] # fmt: skip
|
||||
assert b["top"][7][2] == "true" and b["top"][3][2] == "false"
|
||||
assert b["top"][6][2] == "true" and b["top"][2][2] == "false"
|
||||
# 단독 위 탭 — 위 단추가 탭 키도 가짐 · 아래 줄 = 숨긴 단추 한 개
|
||||
assert c["blank"] and c["role"] == "presentation" and len(c["sub"]) == 1
|
||||
assert c["top"][0][3] == "cost" and c["top"][0][4] == "cost" and c["top"][3][4] is None
|
||||
|
||||
@@ -121,7 +121,7 @@ def test_묶음_금액_자손_합_공종_합_그대로():
|
||||
rows = _rows(sheets)
|
||||
g2, g1 = rows["hand:g2"], rows["hand:g1"]
|
||||
assert (g2["sum"], g2["labor"], g2["material"], g2["expense"]) == (2460, 2000, 400, 60)
|
||||
assert (g2["blank"], g2["note"]) == (1, "빈 줄 1")
|
||||
assert (g2["blank"], g2["note"]) == (1, "") # 257-2 비고 빈 줄 없음
|
||||
assert (g1["sum"], g1["labor"], g1["blank"]) == (12300 + 2460, 12000, 1) # a + b(+ c 빈 줄)
|
||||
assert rows["hand:p"]["sum"] == 0 and rows["hand:p"]["note"] == ""
|
||||
# 공종 합 · 원가계산서 sums · 계 = 묶음 없을 때와 같음(묶음 줄 안 셈)
|
||||
|
||||
@@ -21,7 +21,8 @@ BODY = f"""
|
||||
const user = {{ estimate: {{}} }};
|
||||
const ctx = {{ sheets: {{ total: computeTotal({json.dumps(SUMS)}, user.estimate) }}, payload: {{}}, user, setUser: () => {{}} }};
|
||||
const pack = (t) => t.rows.map((r, i) => [r.key, r.level, t.rowClass(i), t.level.key, t.level.of(i), t.cellClass ?? null]);
|
||||
console.log(JSON.stringify({{ total: pack(buildTotal(ctx)), cost: pack(buildCost(ctx)) }}));
|
||||
const T = buildTotal(ctx), C = buildCost(ctx);
|
||||
console.log(JSON.stringify({{ total: pack(T), cost: pack(C), fixed: {{ total: T.total, cost: C.total }} }}));
|
||||
"""
|
||||
|
||||
|
||||
@@ -41,8 +42,9 @@ def _run() -> dict:
|
||||
|
||||
def test_총괄_레벨():
|
||||
rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["total"]}
|
||||
top = ["direct", "net_cost", "da", "supply_price", "contract", "grand"]
|
||||
top = ["direct", "net_cost", "da", "supply_price", "contract"]
|
||||
assert all(rows[k] == (1, "is-title") for k in top)
|
||||
assert "grand" not in rows # 257-2 총공사비 = 표 아래 고정 줄(total)
|
||||
for k in ["in_price", "in_env", "indirect_labor", "machine", "retirement", "overhead", "vat",
|
||||
"supply"]: # fmt: skip
|
||||
assert rows[k] == (2, None), k
|
||||
@@ -51,8 +53,9 @@ def test_총괄_레벨():
|
||||
|
||||
def test_원가계산서_레벨():
|
||||
rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["cost"]}
|
||||
for k in ["overhead", "profit", "supply_price", "vat", "contract", "supply", "grand"]:
|
||||
for k in ["overhead", "profit", "supply_price", "vat", "contract", "supply"]:
|
||||
assert rows[k] == (1, "is-title"), k
|
||||
assert "grand" not in rows # 257-2 총공사비 = 표 아래 고정 줄(total)
|
||||
for k in ["material", "labor", "expense_sum"]:
|
||||
assert rows[k] == (2, None), k
|
||||
for k in ["direct_material", "scrap", "direct_labor", "indirect_labor", "expense", "safety"]:
|
||||
@@ -74,3 +77,10 @@ def test_CSS_옛_레벨_규칙_없음_공용_표로():
|
||||
r"td\.ui-data-table__lv \{\s*padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* var\(--dt-indent\)\);",
|
||||
common,
|
||||
)
|
||||
|
||||
|
||||
def test_총공사비_아래_고정_줄_총괄_원가계산서():
|
||||
fixed = _run()["fixed"]
|
||||
assert fixed["total"]["key"] == "grand" and fixed["total"]["name"] == "총공사비"
|
||||
assert fixed["cost"]["key"] == "grand" and fixed["cost"]["group"] == "총공사비"
|
||||
assert fixed["cost"]["amount"] == fixed["total"]["amount"] # 원가계산서 = 총괄 거울
|
||||
|
||||
@@ -75,7 +75,7 @@ console.log(JSON.stringify({
|
||||
assert out["a"]["levels"] == ["들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓"]
|
||||
assert out["a"]["guideIn"] is True # 안내 글 = 단추 줄이 아니라 따로(표 아래로)
|
||||
assert out["guideTag"] == "P" and "ui-general-block__empty" in out["guideCls"]
|
||||
assert out["subBlank"] # 하위 탭 없는 묶음 = 빈 보조 줄(숨은 단추 + 도구)
|
||||
assert not out["subBlank"] # 257-1 설계내역 = 아래 탭 둘(총괄 · 설계내역서) · 빈 보조 줄 아님
|
||||
assert out["disabledBefore"] == [True] * 4 # 고른 줄 없음 = 흐림
|
||||
assert out["disabledAfter"] == [True, False, False, True] # 줄 누름 = 메뉴 항목대로
|
||||
assert out["b"] == [] # 다른 탭(일위대가표) = 단추 없음
|
||||
|
||||
@@ -122,7 +122,7 @@ def test_Shell_배선_공용_표_스크롤만():
|
||||
shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert "if (bill) jumpTo(bill.rows, at);" in shell
|
||||
assert "pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in shell
|
||||
assert "table.focusRow(at, !bill)" in shell
|
||||
assert "table.focusRow(at, false)" in shell # 257-2 모든 탭 빨강 = 스크롤만
|
||||
common = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8")
|
||||
assert "focusRow: (index: number, mark?: boolean) => void;" in common
|
||||
assert "state.focus = mark ? index : null;" in common
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""243-1 · 126 — B08 · B09 값 고칠 수 있는 표 전부 더블클릭 입력 · 고칠 칸 테두리 = 246-1 에서 지움.
|
||||
"""243-1 · 126 — B08 · B09 값 고칠 수 있는 표 전부 더블클릭 입력 · 고칠 칸 테두리 = 258-2 에서 옅게 되살림.
|
||||
|
||||
- 공용 표 editOnDouble = 한 번 누름 = 글 상자 안 열림 · 더블클릭 · 칸 누른 뒤 Enter · F2 = 그 칸 글 상자
|
||||
- 고칠 수 있는 칸(td) 표시 `is-can-edit` 없음(246-1 · 241 앞 모양)
|
||||
- 고칠 수 있는 칸(td) = 글일 때도 `is-can-edit`(옅은 outline · 258-2) · 못 고치는 칸 · 옛 방식 표 = 없음
|
||||
- B09 = 표 만드는 길 한 곳(Shell) · B08 = 고칠 칸 있는 표(자재 집계표 셋) 모두 editOnDouble
|
||||
- 칸 누름으로 여는 메뉴 · 고르기 · 이동(onCellClick · choices · link)은 그대로
|
||||
"""
|
||||
@@ -76,7 +76,7 @@ setTimeout(() => {
|
||||
|
||||
def test_공용_표_더블클릭_Enter_F2():
|
||||
out = json.loads(_node(TABLE + HARNESS + SCENE_JS))
|
||||
assert out["can"] == [] # 246-1 고칠 칸 td 표시 없음
|
||||
assert out["can"] == ["3"] # 고칠 수 있는 칸만 · 글일 때도 표시(258-2)
|
||||
assert out["start"] == [] # 처음 = 글 상자 없음
|
||||
assert out["click"] == [[], ["click 1:qty"]] # 한 번 누름 = 안 열림 · 누름 알림은 그대로
|
||||
assert out["f2"] == ["1:qty"] # F2 = 누른 칸 입력
|
||||
@@ -90,10 +90,18 @@ def test_공용_표_더블클릭_Enter_F2():
|
||||
assert out["old"] == [["1:qty"], []] # editOnDouble 없는 표 = 예전대로(테두리 class 없음)
|
||||
|
||||
|
||||
def test_고칠_칸_테두리_CSS_없음():
|
||||
def test_고칠_칸_테두리_CSS_옅게_테마_변수만():
|
||||
css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8")
|
||||
ts = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8")
|
||||
assert "is-can-edit" not in css and "is-can-edit" not in ts # 246-1 되돌림
|
||||
m = re.search(r"\.ui-data-table__table td\.is-can-edit \{([^}]*)\}", css)
|
||||
assert m
|
||||
body = m.group(1)
|
||||
assert (
|
||||
"outline: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-canvas));"
|
||||
in body
|
||||
)
|
||||
assert "outline-offset: -1px;" in body # 칸 크기 불변
|
||||
assert "box-shadow" not in body # 빨강 이동 상자(box-shadow)와 안 겹침
|
||||
assert "#" not in body # 하드코딩 색 없음
|
||||
|
||||
|
||||
def _sources(*folders: str) -> list[Path]:
|
||||
|
||||
@@ -150,10 +150,8 @@ def test_화면_모달_팬줌_그림탭():
|
||||
place = _read("B08_Quantity/B08_Quantity_UI_Milestone_Place.ts")
|
||||
assert 'from "../B01_Dashboard/B01_Dashboard_Api_Fetch"' in place
|
||||
assert "searchAddress(text)" in place
|
||||
assert 'from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"' in modal # 공사지 모달 지도
|
||||
# ② 시점 모달 — 등록 주소 · VWorld 주소 · 지도
|
||||
assert "/quantity/milestone/site-address" in modal and "map.showPoint(" in modal
|
||||
assert "프로젝트 등록 주소" in modal and "시점 브이월드 주소" in modal
|
||||
# ② 시점 모달 — 지도 = 공용 카카오 지도(255-1 · 주소 줄 지움)
|
||||
assert "/quantity/milestone/site-address" in modal and "editMap(projectId" in modal
|
||||
# ⑤ 거래처 상자 = 같은 등록 모달(고침 · 삭제 · 244-13) · 구간 km · 도로 구분 = 연결선 모달
|
||||
for text in ('"수정"', 'label: "삭제"', "rowValues()", "putMilestone(projectId, fresh)"):
|
||||
assert text in add
|
||||
|
||||
@@ -116,8 +116,8 @@ def test_표_폭_다이어그램_폭_좌측_카드_없음():
|
||||
assert "milestoneCardBody" not in _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
|
||||
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
|
||||
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
|
||||
assert 'label: "중점 측점 (km)"' in site and 'label: "시점 → 중점 도로"' in site
|
||||
assert 'setLeg(fresh, "start", 0, {' in site and "putMilestone(" in site
|
||||
# 244-13 — 기준점 = 등록 모달 분류 「기준위치」 · [기준점 +] 없앰 · 254 여럿 · 삭제 = 그 기준점만
|
||||
base = modal[
|
||||
modal.index("export function openPlaceModal") : modal.index("interface SiteAddress")
|
||||
|
||||
@@ -16,11 +16,12 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
OLD_ADDS = ["위에 줄 추가", "아래에 줄 추가", "아래에 하위 줄 추가", "새 공종 추가(1단)"]
|
||||
|
||||
|
||||
def test_입력_칸_테두리_없음():
|
||||
def test_입력_칸_채움_색_td_테두리_옅게():
|
||||
css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8")
|
||||
body = css.split(".ui-data-table__input {")[1].split("}")[0]
|
||||
assert "background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas));" in body
|
||||
assert "box-shadow" not in body and "is-can-edit" not in css
|
||||
assert "box-shadow" not in body
|
||||
assert "td.is-can-edit {" in css # 258-2 — 고칠 칸 옅은 outline 되살림
|
||||
|
||||
|
||||
def test_더함_하나_줄_삭제_연결_이름_셋():
|
||||
|
||||
@@ -29,7 +29,7 @@ LINES = [[[35.6866, 127.9095], [35.66, 127.85]]]
|
||||
MARKS = [{"name": "거창군청", "lon": 127.9095, "lat": 35.6866}]
|
||||
|
||||
if __name__ == "__main__": # 자식 프로세스 — 카카오 페이지만 단색 페이지로
|
||||
cap.page_html = lambda key, lines, marks: HTML
|
||||
cap.page_html = lambda key, lines, marks, view=None: HTML
|
||||
print(json.dumps(cap.capture(json.loads(sys.stdin.buffer.read()))))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""253-1 거리이정표 행 1:1 → B09 운반공 줄 — 자동 상자 = 옛 키 · 사용자 상자 = `haul:r-{id}`."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Haul import haul_rows, route_bill_key # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Bill_Items import haul_items # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Router import haul_user # noqa: E402
|
||||
|
||||
LOGIC = {"tab": "logic", "code": "GF000134", "name": "골재운반", "given": {}}
|
||||
DATA = {
|
||||
"site": {"mid_station_m": 2000, "mid_road": "비포장"},
|
||||
"base": {"enabled": False, "legs": []},
|
||||
"routes": [
|
||||
{"id": "h-sand", "name": "골재상", "haul": "sand", "via_base": False,
|
||||
"legs": [{"road": "포장", "km": 25.1}], "rows": [{"item": "모래", "dest": "start"}]},
|
||||
{"id": "u1", "name": "석산", "ref": LOGIC, "via_base": False,
|
||||
"legs": [{"road": "포장", "km": 10}, {"road": "비포장", "km": 0.8}],
|
||||
"rows": [{"item": "혼합골재", "dest": "start"}, {"item": "깬돌", "dest": "mid"}]},
|
||||
],
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def test_keys():
|
||||
assert route_bill_key({"id": "h-sand", "haul": "sand"}) == "haul:3"
|
||||
assert route_bill_key({"id": "h-x", "haul": "remicon"}) == "haul:remicon"
|
||||
assert route_bill_key({"id": "u1"}) == "haul:r-u1"
|
||||
assert route_bill_key({"id": "u1"}, 1) == "haul:r-u1.1"
|
||||
|
||||
|
||||
def test_rows_one_per_table_row():
|
||||
rows = haul_rows(DATA, compute_milestone(DATA, None))
|
||||
got = {r["key"]: r for r in rows}
|
||||
assert list(got) == ["haul:3", "haul:r-u1", "haul:r-u1.1"]
|
||||
assert (got["haul:3"]["L1"], got["haul:3"]["L2"], got["haul:3"]["user"]) == (25.1, 0, False)
|
||||
assert (got["haul:r-u1"]["name"], got["haul:r-u1"]["L1"], got["haul:r-u1"]["L2"]) == (
|
||||
"혼합골재운반", 10, 0.8) # fmt: skip
|
||||
assert got["haul:r-u1.1"]["L2"] == 2.8 # 중점 = 시점 → 중점 2 km 비포장 더함
|
||||
assert got["haul:r-u1"]["ref"] == LOGIC
|
||||
|
||||
|
||||
def test_bill_items_user_rows():
|
||||
rows = haul_rows(DATA, compute_milestone(DATA, None))
|
||||
extra = haul_user(rows)
|
||||
items = {i["key"]: i for i in haul_items(None, extra["haul_refs"], rows)}
|
||||
assert (
|
||||
"haul:3" in items and items["haul:3"]["name"] == "모래운반"
|
||||
) # 자동 = 총괄자재 묶음 그대로
|
||||
user = items["haul:r-u1"]
|
||||
assert (user["section"], user["name"], user["qty"], user["ref"]) == (
|
||||
"3",
|
||||
"혼합골재운반",
|
||||
None,
|
||||
LOGIC,
|
||||
)
|
||||
assert items["haul:r-u1.1"]["name"] == "깬돌운반" and items["haul:r-u1.1"]["ref"] == LOGIC
|
||||
assert extra["haul_dist"]["haul:r-u1"] == {"L1": 10, "L2": 0.8}
|
||||
@@ -0,0 +1,114 @@
|
||||
"""253-2 운반 사이클시간 짝 — 거창 25 설계서 단가산출 1308~1347행 재현 · Py ↔ TS 거울 ·
|
||||
로직 입력 이름 채움 · B09 셈(hand_sources) 때 거리이정표 거리로 다시 채움."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Haul_Cycle import cycle_time, fill_given # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Sangeun import hand_sources # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Store import ref_rows # noqa: E402
|
||||
|
||||
XLSX = next(
|
||||
(ROOT / "resources/knowledge/original/실무문서").glob(
|
||||
"01.산불거창*/01.내역서/00.25년_산불_거창*내역+수량.xlsx"
|
||||
),
|
||||
None,
|
||||
)
|
||||
#: 칸 → 입력 이름(단가산출 시트 · 32.혼합골재 운반(덤프15톤))
|
||||
CELLS = {"S1318": "L1", "AR1318": "L2", "R1320": "V1", "R1321": "V2", "AQ1320": "V3",
|
||||
"AQ1321": "V4", "K1323": "T", "AB1323": "rt", "Q1325": "f", "L1327": "qs",
|
||||
"Z1327": "k", "AL1327": "Es", "BA1327": "Cms", "L1337": "t3", "X1337": "t4",
|
||||
"AK1337": "t5", "AW1337": "t6"} # fmt: skip
|
||||
|
||||
|
||||
@pytest.mark.skipif(XLSX is None, reason="거창 설계서 없음")
|
||||
def test_거창_혼합골재_운반_재현():
|
||||
import openpyxl
|
||||
|
||||
ws = openpyxl.load_workbook(XLSX, read_only=False)["단가산출"]
|
||||
inputs = {}
|
||||
for cell, name in CELLS.items():
|
||||
text = str(ws[cell].value) # =TEXT(25.1,"#,##0.#######")
|
||||
inputs[name] = float(re.match(r"=TEXT\(([\d.]+),", text).group(1))
|
||||
# 식 줄이 같은 꼴인지(t2 · Cm)
|
||||
assert (
|
||||
"(S1318)/(R1320)+(S1318)/(R1321)+(AR1318)/(AQ1320)+(AR1318)/(AQ1321)" in ws["BX1334"].value
|
||||
)
|
||||
assert "BQ1331+BX1334+L1337+X1337+AK1337+AW1337" in ws["CE1339"].value
|
||||
assert (inputs["L1"], inputs["L2"]) == (25.1, 0.8)
|
||||
got = cycle_time(inputs)
|
||||
assert (got["q"], got["n"], got["t1"]) == (8.82, 5.13, 5.33)
|
||||
assert (got["t2"], got["cm"]) == (101.23, 109.78)
|
||||
assert cycle_time({"L1": 25.1, "L2": 0.8}) == got # 기본값 = 거창 값
|
||||
|
||||
|
||||
CASES = [
|
||||
{"L1": 25.1, "L2": 0.8},
|
||||
{"L1": 3.333, "L2": 1.257, "V1": 20, "V2": 25, "V3": 7, "V4": 8, "t5": 3.77},
|
||||
{"L1": 0, "L2": 0, "t1": 2.5, "V3": 0},
|
||||
{"L1": 12.5, "L2": 4.05, "T": 24, "rt": 1.8, "f": 0.8, "qs": 1.2, "k": 0.9, "Es": 0.75},
|
||||
]
|
||||
|
||||
|
||||
def test_거울_ts():
|
||||
calc = ROOT / "B08_Quantity" / "B08_Quantity_Haul_Cycle.ts"
|
||||
body = (
|
||||
f"\nconst cases = {json.dumps(CASES)};"
|
||||
"\nconsole.log(JSON.stringify(cases.map((c) => cycleTime(c))));"
|
||||
"\nconst ref = {given: {qt: 1, Cm: 9}, cycle: {L1: 99, t1: 7}, own: ['L1']};"
|
||||
"\nconst got = fillGiven(ref, ['Cm', 't2', '운반거리'], {L1: 25.1, L2: 0.8});"
|
||||
"\nconsole.log(JSON.stringify(got));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", calc.read_text(encoding="utf-8") + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
ts, ts_fill = (json.loads(line) for line in result.stdout.strip().splitlines())
|
||||
assert ts == [cycle_time(c) for c in CASES]
|
||||
ref = {"given": {"qt": 1, "Cm": 9}, "cycle": {"L1": 99, "t1": 7}, "own": ["L1"]}
|
||||
assert ts_fill == fill_given(ref, ["Cm", "t2", "운반거리"], {"L1": 25.1, "L2": 0.8})
|
||||
|
||||
|
||||
def test_입력_이름_채움():
|
||||
ref = {"given": {"qt": 15}, "cycle": {}}
|
||||
got = fill_given(ref, ["qt", "Cm", "지역"], {"L1": 25.1, "L2": 0.8})
|
||||
assert got == {"qt": 15, "Cm": 109.78}
|
||||
assert fill_given(ref, ["운반거리"], {"L1": 25.1, "L2": 0.8}) == {"qt": 15, "운반거리": 25.9}
|
||||
assert fill_given(ref, ["사이클시간", "t2"], {"L1": 25.1, "L2": 0.8}) == {
|
||||
"qt": 15, "사이클시간": 109.78, "t2": 101.23} # fmt: skip
|
||||
# 덮은 칸 = 그 값 · 덮은 L1 = 거리 대신 사이클 값
|
||||
own = {"given": {"Cm": 50}, "own": ["Cm"]}
|
||||
assert fill_given(own, ["Cm"], {"L1": 25.1, "L2": 0.8}) == {"Cm": 50}
|
||||
mine = {"given": {}, "cycle": {"L1": 10}, "own": ["L1"]}
|
||||
assert fill_given(mine, ["운반거리"], {"L1": 25.1, "L2": 0.8}) == {"운반거리": 10.8}
|
||||
|
||||
|
||||
def test_서버_셈_거리로_다시_채움():
|
||||
ref = {"tab": "logic", "code": "GF000134", "name": "골재운반", "given": {"Cm": 1}, "cycle": {}}
|
||||
user = {"haul_refs": {"haul:r-u1": ref}, "haul_dist": {"haul:r-u1": {"L1": 25.1, "L2": 0.8}}}
|
||||
((_key, _use, src),) = hand_sources(user)
|
||||
assert src["given"]["Cm"] == 109.78
|
||||
user["haul_dist"]["haul:r-u1"] = {"L1": 10, "L2": 0} # 거리 바뀌면 따라감
|
||||
((_key, _use, src),) = hand_sources(user)
|
||||
assert src["given"]["Cm"] == 45.69 and "운반거리" not in src["given"] # 그 로직 입력만
|
||||
|
||||
|
||||
def test_덮개_ref_사이클_남김():
|
||||
from B09_Estimation.B09_Estimation_Store import _clean_ref
|
||||
|
||||
ref = {"tab": "logic", "code": "GF000134", "given": {"Cm": 3}, "cycle": {"V1": 20, "x": "a"},
|
||||
"own": ["Cm", 3]} # fmt: skip
|
||||
got = _clean_ref(ref)
|
||||
assert got["cycle"] == {"V1": 20} and got["own"] == ["Cm"]
|
||||
rows = ref_rows({"bill": {"rows": [{"key": "haul:r-u1", "ref": got}]}})
|
||||
assert rows[0]["ref"]["cycle"] == {"V1": 20}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""255-1 거리이정표 시점 모달 — 중점 측점 km 로 보이고 받음(저장은 m) · 설명 줄 지움 ·
|
||||
지도 = 공용 카카오 지도(시점 · 중점 표지)."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
|
||||
|
||||
def _read(rel):
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _node(expr):
|
||||
src = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e",
|
||||
IMPORTS.sub("", 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 _site(km):
|
||||
data = {"site": {"mid_station_m": 999, "mid_road": "비포장"}, "base": {}, "routes": []}
|
||||
return _node(f'setLeg({json.dumps(data)}, "start", 0, {{ km: {json.dumps(km)} }}).site')
|
||||
|
||||
|
||||
def test_중점_km_저장_m():
|
||||
assert _site(1.2345)["mid_station_m"] == 1234.5
|
||||
assert _site(0.0005)["mid_station_m"] == 0.5
|
||||
assert _site(None)["mid_station_m"] is None # 빈칸 = 노선 ÷ 2
|
||||
|
||||
|
||||
def test_시점_모달_km_카카오_지도():
|
||||
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
|
||||
site = modal[modal.index("export function openSiteInfo") :]
|
||||
assert 'label: "중점 측점 (km)"' in site
|
||||
assert "String(data.site.mid_station_m / 1000)" in site # 보임 = km
|
||||
assert 'setLeg(fresh, "start", 0, {' in site # 받음 km → 저장 m
|
||||
# 설명 줄 · 등록 주소 · 브이월드 주소 줄 · B01 타일 지도 없음
|
||||
for gone in ("시점 = 노선 첫 점", "프로젝트 등록 주소", "브이월드 주소", "buildAddressMap"):
|
||||
assert gone not in modal
|
||||
assert "editMap(projectId" in site
|
||||
assert '["start", "시점", s.start_lonlat]' in site and '["mid", "중점", s.mid_lonlat]' in site
|
||||
assert 'dialogClass: "b08-milestone__modal"' in site # 다른 모달과 같은 창 · 지도 높이
|
||||
@@ -0,0 +1,119 @@
|
||||
"""255-2 거리이정표 경로 탭 — 시점 끌기 = 길찾기용 덮개(site.start_pin · 노선 그대로) ·
|
||||
표지 옮기면 그 자리에 닿는 구간(나가는 · 들어오는) 모두 다시 길찾기 · 열 때 사슬 구간 모두 선 ·
|
||||
실패 안내 · 끌기는 그림 캡처 안 부름."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
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
|
||||
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
STUBS = """
|
||||
let cache = null;
|
||||
const sent = [];
|
||||
const API_BASE_URL = "";
|
||||
const showToast = () => undefined;
|
||||
const readState = () => cache;
|
||||
const writeState = (_key, d) => { cache = d; };
|
||||
const clearState = () => { cache = null; };
|
||||
const request = async (url, opts) => {
|
||||
if (url.endsWith("/quantity/milestone"))
|
||||
return { data: DATA, site: { length_m: 1000, mid_default_m: 500, start_lonlat: [127, 35],
|
||||
mid_lonlat: null }, images: {}, auto_images: {} };
|
||||
sent.push(JSON.parse(opts.body));
|
||||
return { km: 1.5, reason: null, path: [] };
|
||||
};
|
||||
"""
|
||||
|
||||
|
||||
def _read(rel):
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _place(pid, to, lon, lat, **more):
|
||||
return {"id": pid, "name": pid, "address": "", "dir": None, "lon": lon, "lat": lat,
|
||||
"legs": [], "to": to, **more} # fmt: skip
|
||||
|
||||
|
||||
DATA = {
|
||||
"site": {"mid_station_m": None, "mid_road": "비포장"},
|
||||
"bases": [_place("b1", "site", 127.5, 35.5), _place("b2", "b1", 127.6, 35.6)],
|
||||
"routes": [
|
||||
_place("r1", "site", 127.2, 35.2, rows=[]),
|
||||
_place("r2", "b1", 127.3, 35.3, rows=[]),
|
||||
_place("r3", "b2", 127.4, 35.4, rows=[]),
|
||||
_place("r4", "b1", 127.1, 35.1, rows=[], linked=False),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _run(script):
|
||||
src = "".join(
|
||||
IMPORTS.sub("", _read(f"B08_Quantity/B08_Quantity_{n}.ts"))
|
||||
for n in ("Milestone_Calc", "Milestone_Layout", "UI_Milestone_State")
|
||||
)
|
||||
code = f"const DATA = {json.dumps(DATA)};\n{STUBS}\n{src}\n{script}"
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"],
|
||||
input=code, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_시점_덮개_길찾기():
|
||||
got = _run("""
|
||||
await loadMilestone("p");
|
||||
await findSegmentKm("p", "r1", false);
|
||||
const d = liveMilestone("p");
|
||||
d.site.start_pin = { lon: 128.25, lat: 36.5 };
|
||||
putMilestone("p", d);
|
||||
await findSegmentKm("p", "r1", false);
|
||||
await findSegmentKm("p", "b1", false);
|
||||
console.log(JSON.stringify({ sent, start: startPoint(liveMilestone("p")) }));
|
||||
""")
|
||||
dest = [s["destination"] for s in got["sent"]]
|
||||
assert dest[0] == {"lon": 127, "lat": 35} # 덮개 없음 = 노선 첫 점
|
||||
assert dest[1] == dest[2] == {"lon": 128.25, "lat": 36.5} # 거래처 · 기준점 → 시점 = 덮개
|
||||
assert got["start"] == [128.25, 36.5]
|
||||
|
||||
|
||||
def test_닿는_구간_모두():
|
||||
got = _run("""
|
||||
console.log(JSON.stringify(["start", "b1", "b2", "r1", "r3"].map((k) => touchingSegments(DATA, k))));
|
||||
""")
|
||||
start, b1, b2, r1, r3 = got
|
||||
assert start == ["b1", "r1"] # 시점에 들어오는 구간
|
||||
assert b1 == ["b1", "b2", "r2"] # 나가는 구간 + 들어오는 경로들(안 이은 r4 빠짐)
|
||||
assert b2 == ["b2", "r3"]
|
||||
assert r1 == ["r1"] and r3 == ["r3"]
|
||||
|
||||
|
||||
def test_서버_모델_시점_덮개():
|
||||
got = ms.Milestone.model_validate({"site": {"start_pin": {"lon": 127.5, "lat": 35.5}}})
|
||||
assert got.model_dump()["site"]["start_pin"] == {"lon": 127.5, "lat": 35.5}
|
||||
assert ms.Milestone().model_dump()["site"]["start_pin"] is None
|
||||
with pytest.raises(ValidationError):
|
||||
ms.Milestone.model_validate({"site": {"start_pin": {"lon": 500, "lat": 35}}})
|
||||
|
||||
|
||||
def test_경로_탭_끌기_다시_길찾기():
|
||||
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
|
||||
assert "movable: true" in route and 'movable: key !== "start"' not in route
|
||||
assert "fresh.site.start_pin = { lon, lat };" in route
|
||||
assert "await refind(touchingSegments(fresh, key));" in route
|
||||
assert "findSegmentKm(projectId, id, false)" in route # 끌기 · 열기 = 그림 캡처 없음
|
||||
assert "[routeId, ...chain].filter((id) => !segmentPath(id).length)" in route # 열 때 모든 구간
|
||||
assert "status.hidden = !bad.length;" in route # 실패 안내
|
||||
calc = _read("B08_Quantity/B08_Quantity_Milestone_Calc.ts")
|
||||
assert "start_pin?: { lon: number; lat: number } | null;" in calc
|
||||
@@ -0,0 +1,160 @@
|
||||
"""256-1 B08 거리이정표 장소 그림 영구 저장(사용자 162-6 · 7).
|
||||
|
||||
캡처는 가짜(make_place_image 자리)로:
|
||||
① 장소 그림 페이지 = 480×320 · 고정 줌 · 경로 맞춤 안 함
|
||||
② [저장] 뒤 좌표 있는 상자(거래처 = 경로 id · 기준점 = b1)만 캡처 → place/<id>.png ·
|
||||
GET 답 place_images · place_times
|
||||
③ 다시 [저장] = 바뀐 상자 · 그림 없는 상자만 · 실패 = 다음에 다시 · 지문에 좌표 원문 없음
|
||||
④ 지운 상자 그림 = [저장] 때 지움 · 화면 캡처 길(place-image) · 그림 파일 길(file · 경로 넘기 막음)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
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_Milestone_Capture as cap # noqa: E402
|
||||
from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\nfake-place"
|
||||
DATA = {
|
||||
"base": {"enabled": True, "name": "거창IC", "lon": 127.91, "lat": 35.68},
|
||||
"routes": [
|
||||
{"id": "r1", "name": "거창레미콘", "lon": 127.9095, "lat": 35.6866},
|
||||
{"id": "r2", "name": "좌표 없음"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class Fake:
|
||||
"""가짜 캡처 — 부른 상자 기록 · ok=False 면 실패."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.ok = True
|
||||
|
||||
def __call__(self, out_dir, place_id, mark):
|
||||
self.calls.append((place_id, mark["name"], mark["lon"], mark["lat"]))
|
||||
if not self.ok:
|
||||
return {"saved": False, "reason": "장소 그림 자동 캡처 실패 — 시험"}
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
(out_dir / f"{place_id}.png").write_bytes(PNG)
|
||||
return {"saved": True, "reason": None}
|
||||
|
||||
|
||||
def _save(root, data):
|
||||
return ms.save_data(root, ms.Milestone.model_validate(data))
|
||||
|
||||
|
||||
def _fake(monkeypatch):
|
||||
fake = Fake()
|
||||
monkeypatch.setattr(ms, "make_place_image", fake)
|
||||
return fake
|
||||
|
||||
|
||||
def test_장소_그림_페이지_작은_지도():
|
||||
html = cap.page_html(
|
||||
"k", [], [{"name": "거창레미콘", "lon": 127.9, "lat": 35.6}], cap.PLACE_VIEW
|
||||
)
|
||||
assert "width:480px;height:320px" in html and "level: 4" in html
|
||||
assert "if (false)" in html # 경로 맞춤 안 함 — 표지 가운데 · 고정 줌
|
||||
assert "if (true)" in cap.page_html("k", [], []) # 경로 그림은 그대로
|
||||
|
||||
|
||||
def test_저장_뒤_좌표_있는_상자만_캡처(tmp_path, monkeypatch):
|
||||
fake = _fake(monkeypatch)
|
||||
_save(tmp_path, DATA)
|
||||
ms.refresh_images(tmp_path)
|
||||
assert sorted(c[0] for c in fake.calls) == ["b1", "r1"]
|
||||
got = ms.payload(tmp_path, ms.read_data(tmp_path))
|
||||
assert got["place_images"] == {"b1": "place/b1.png", "r1": "place/r1.png"}
|
||||
assert set(got["place_times"]) == {"b1", "r1"}
|
||||
assert all(isinstance(t, int) and t > 0 for t in got["place_times"].values())
|
||||
assert (tmp_path / "B08_Quantity/milestone/place/r1.png").read_bytes() == PNG
|
||||
prints = (tmp_path / "B08_Quantity/milestone/.prints.json").read_text(encoding="utf-8")
|
||||
assert "127.9095" not in prints and "35.6866" not in prints # 해시만
|
||||
|
||||
|
||||
def test_바뀐_상자_그림_없는_상자만_다시(tmp_path, monkeypatch):
|
||||
fake = _fake(monkeypatch)
|
||||
_save(tmp_path, DATA)
|
||||
ms.refresh_images(tmp_path)
|
||||
fake.calls.clear()
|
||||
ms.refresh_images(tmp_path)
|
||||
assert fake.calls == [] # 안 바뀜 = 캡처 0
|
||||
moved = {**DATA, "routes": [{**DATA["routes"][0], "lat": 35.7}, DATA["routes"][1]]}
|
||||
_save(tmp_path, moved)
|
||||
ms.refresh_images(tmp_path)
|
||||
assert [c[0] for c in fake.calls] == ["r1"]
|
||||
fake.calls.clear()
|
||||
(tmp_path / "B08_Quantity/milestone/place/b1.png").unlink()
|
||||
ms.refresh_images(tmp_path)
|
||||
assert [c[0] for c in fake.calls] == ["b1"]
|
||||
# 실패 = 지문 안 적음 → 다음에 다시
|
||||
fake.calls.clear()
|
||||
fake.ok = False
|
||||
_save(tmp_path, {**moved, "base": {**DATA["base"], "lon": 127.95}})
|
||||
ms.refresh_images(tmp_path)
|
||||
ms.refresh_images(tmp_path)
|
||||
assert [c[0] for c in fake.calls] == ["b1", "b1"]
|
||||
|
||||
|
||||
def test_지운_상자_그림은_저장_때_지움(tmp_path, monkeypatch):
|
||||
_fake(monkeypatch)
|
||||
_save(tmp_path, DATA)
|
||||
ms.refresh_images(tmp_path)
|
||||
got = _save(tmp_path, {"routes": [DATA["routes"][1]]}) # r1 · 기준점 좌표 지움
|
||||
assert got["place_images"] == {} and got["place_times"] == {}
|
||||
assert not list((tmp_path / "B08_Quantity/milestone/place").iterdir())
|
||||
|
||||
|
||||
def test_화면_캡처_길_그림_파일_길(tmp_path, monkeypatch):
|
||||
fake = _fake(monkeypatch)
|
||||
|
||||
async def root(project_id):
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(ms, "_project_root", root)
|
||||
app = FastAPI()
|
||||
app.include_router(ms.router)
|
||||
client = TestClient(app)
|
||||
base = f"/api/projects/{uuid4()}/quantity/milestone"
|
||||
# [저장] = 답 뒤 캡처(BackgroundTasks — TestClient 는 답 전에 끝냄)
|
||||
assert client.put(base, json=DATA).status_code == 200
|
||||
assert sorted(c[0] for c in fake.calls) == ["b1", "r1"]
|
||||
assert client.get(base).json()["place_images"]["r1"] == "place/r1.png"
|
||||
# 등록 · 끌기 뒤 화면이 부름
|
||||
fake.calls.clear()
|
||||
mark = {"name": "새 거래처", "lon": 127.8, "lat": 35.5}
|
||||
res = client.post(base + "/place-image", params={"id": "r9"}, json=mark).json()
|
||||
assert res["name"] == "place/r9.png" and res["reason"] is None and res["time"] > 0
|
||||
assert fake.calls == [("r9", "새 거래처", 127.8, 35.5)]
|
||||
fake.ok = False
|
||||
bad = client.post(base + "/place-image", params={"id": "r8"}, json=mark).json()
|
||||
assert bad == {"name": None, "time": None, "reason": "장소 그림 자동 캡처 실패 — 시험"}
|
||||
assert client.post(base + "/place-image", params={"id": "../x"}, json=mark).status_code == 400
|
||||
# 그림 파일 길 — 하위 폴더 둘 · 경로 넘기 막음
|
||||
assert client.get(base + "/file", params={"name": "place/r1.png"}).content == PNG
|
||||
assert client.get(base + "/file", params={"name": "place/zz.png"}).status_code == 404
|
||||
for name in ("../milestone.json", "place/../x.png", "x/r1.png", "place/r1.json", "/etc/x.png"):
|
||||
assert client.get(base + "/file", params={"name": name}).status_code == 422, name
|
||||
|
||||
|
||||
def test_여러_기준점_상자_id(tmp_path, monkeypatch):
|
||||
fake = _fake(monkeypatch)
|
||||
bases = [
|
||||
{"id": "b1", "name": "거창IC", "lon": 127.91, "lat": 35.68, "to": "b2"},
|
||||
{"id": "b2", "name": "함양JC", "lon": 127.72, "lat": 35.52},
|
||||
{"id": "b3", "name": "좌표 없음"},
|
||||
]
|
||||
_save(tmp_path, {"bases": bases, "routes": [{**DATA["routes"][0], "to": "b1"}]})
|
||||
ms.refresh_images(tmp_path)
|
||||
assert sorted(c[0] for c in fake.calls) == ["b1", "b2", "r1"]
|
||||
got = _save(tmp_path, {"bases": bases[1:], "routes": []}) # b1 · r1 지움
|
||||
assert got["place_images"] == {"b2": "place/b2.png"}
|
||||
@@ -0,0 +1,58 @@
|
||||
"""257-1 B09 위 탭 「설계내역」(총괄설계내역서 · 설계내역서 한 묶음) · 설계내역서 단가연결 칸 램프.
|
||||
|
||||
- 위 탭 = `TAB_GROUPS` 「설계내역」[total, bill] · 옛 두 위 탭 없음
|
||||
- 단가연결 칸 = 줄 칸 class `b09-bill__lamp`(+ 연결 `is-linked`) · 풍선 = 글(대가N · 연결됨 · 단가연결)
|
||||
- 머리 = 「단가↵연결」 · 폭 44 · 머리 CSS pre-line
|
||||
"""
|
||||
|
||||
import json
|
||||
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 resources.tester.test_184_3_b09_bill_menu import ROWS, SRC, _node, _ts # noqa: E402
|
||||
|
||||
JS = """
|
||||
const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, user: {}, setUser() {} };
|
||||
const table = buildBill(ctx);
|
||||
const at = (key) => table.rows.findIndex((r) => r.key === key);
|
||||
const keys = ["earth:4", "earth:5", "hand:a", "mat:M1", "fee", "sec:1"];
|
||||
const col = table.columns.find((c) => c.key === "connect");
|
||||
console.log(JSON.stringify({
|
||||
col: { label: col.label, width: col.width, title: col.title },
|
||||
cls: keys.map((k) => table.cellClass(at(k), "connect")),
|
||||
tip: keys.map((k) => table.tip(at(k), "connect")),
|
||||
other: table.cellClass(at("earth:4"), "qty"),
|
||||
groups: TAB_GROUPS.map(([label, tabs]) => [label, tabs]),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def test_램프_칸_class_풍선_머리():
|
||||
body = JS.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False))
|
||||
out = json.loads(_node(_ts([*SRC, "UI_Tabs_Bill"]) + body))
|
||||
assert out["col"] == {"label": "단가\n연결", "width": 44, "title": "단가연결"}
|
||||
assert out["cls"] == [
|
||||
"b09-bill__lamp is-linked", # 자동 줄 · 호표 연결
|
||||
"b09-bill__lamp", # 연결 없음 = 노랑
|
||||
"b09-bill__lamp is-linked", # 손 줄 · 비고 대가3
|
||||
None, # 자재 줄 = 연결 대상 아님
|
||||
None,
|
||||
None, # 묶음 줄
|
||||
]
|
||||
assert out["tip"] == ["연결됨", "단가연결", "대가3", None, None, None]
|
||||
assert out["other"] is None
|
||||
labels = [g[0] for g in out["groups"]]
|
||||
assert "설계내역" in labels and "총괄설계내역서" not in labels and "설계내역서" not in labels
|
||||
assert ["설계내역", ["total", "bill"]] in out["groups"]
|
||||
|
||||
|
||||
def test_CSS_램프_초록_노랑_머리_줄바꿈():
|
||||
css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
assert "td.b09-bill__lamp::before" in css and "var(--color-warning)" in css
|
||||
assert "td.b09-bill__lamp.is-linked::before" in css and "var(--color-success)" in css
|
||||
head = css.split("[data-b09-table] thead th {", 1)[1].split("}", 1)[0]
|
||||
assert "white-space: pre-line" in head
|
||||
@@ -0,0 +1,64 @@
|
||||
"""257-2 B09 이동해 온 줄 = 모든 탭 빨강 상자 · 비고 「빈 줄 N」 없음 · 총공사비 = 표 아래 고정 줄.
|
||||
|
||||
- 쉘 = 설계내역서 밖 · 묶음 아닌 탭에도 줄 범위 [at, at] 빨강(pickClass) · 줄 누름 · Esc · 방향키로 풂(unpickOnRelease)
|
||||
- 비고 = 제목 줄 「(제외금액)」만 · 묶음 줄 빈칸(Py · TS 짝 · blank 수는 줄 값 그대로)
|
||||
- 총괄 · 원가계산서 총공사비(grand) = `TabTable.total`(몸 줄에서 빠짐 · 줄 이동 누름 없음 — 185-1 시험이 값 확인)
|
||||
"""
|
||||
|
||||
import re
|
||||
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 resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402
|
||||
|
||||
B09 = ROOT / "B09_Estimation"
|
||||
|
||||
|
||||
def test_쉘_모든_탭_빨강_배선():
|
||||
shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert "!bill && at >= 0 ? [at, at] : null" in shell
|
||||
assert "pickClass(range, i)" in shell and "unpickOnRelease(table.root)" in shell
|
||||
assert "table.focusRow(at, false)" in shell
|
||||
|
||||
|
||||
def test_풂_줄_누름_Esc_빨강만_지움():
|
||||
out = run(
|
||||
[COMMON, TABS, GROUPS],
|
||||
"""
|
||||
const root = document.createElement("div");
|
||||
const rows = [0, 1, 2].map((i) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.i = String(i);
|
||||
if (i === 1) tr.classList.add("is-pick", "is-pick-top", "is-pick-end");
|
||||
root.append(tr);
|
||||
return tr;
|
||||
});
|
||||
unpickOnRelease(root);
|
||||
const before = rows.map((t) => t.className);
|
||||
key("Escape");
|
||||
const afterEsc = rows.map((t) => t.className);
|
||||
rows[1].classList.add("is-pick");
|
||||
root.click();
|
||||
const afterClick = rows.map((t) => t.className);
|
||||
console.log(JSON.stringify({ before, afterEsc, afterClick }));
|
||||
""",
|
||||
RAW
|
||||
+ """
|
||||
E.prototype.querySelectorAll = function () { return this.all("TR").filter((t) => t.cls.has("is-pick")); };
|
||||
""",
|
||||
)
|
||||
assert out["before"] == ["", "is-pick is-pick-top is-pick-end", ""]
|
||||
assert out["afterEsc"] == ["", "", ""]
|
||||
assert out["afterClick"] == ["", "", ""]
|
||||
|
||||
|
||||
def test_비고_빈_줄_없음_Py_TS_짝():
|
||||
py = (B09 / "B09_Estimation_Calc_Bill.py").read_text("utf-8")
|
||||
ts = (B09 / "B09_Estimation_Calc_Bill.ts").read_text("utf-8")
|
||||
assert "빈 줄 {" not in py and not re.search(r"빈 줄 \$\{", ts)
|
||||
assert 'row["note"] = "(제외금액)" if row["key"] == "sec:G" else ""' in py
|
||||
assert 'row.note = row.key === "sec:G" ? "(제외금액)" : ""' in ts
|
||||
@@ -0,0 +1,52 @@
|
||||
"""258-1 · 258-2 · 258-3 — 표 폭 CSS 변수 · 입력 칸 테두리 옅게 · 구조물 조합 미지정 회색(소스 시험).
|
||||
|
||||
- 템플릿 표 인라인 = `--dt-w`(열 합) · css `width: var(--dt-w)` → 페이지 css 가 width 로 덮음
|
||||
- 거리이정표 표 = 틀 폭 100%
|
||||
- 고칠 칸 td.is-can-edit = accent 28% outline · box-shadow 아님
|
||||
- B05 미지정 = 회색(text-muted)
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DT_CSS = ROOT / "ui_template" / "ui_template_data_table.css"
|
||||
DT_TS = ROOT / "ui_template" / "ui_template_data_table.ts"
|
||||
|
||||
|
||||
def _rule(css: str, selector: str) -> str:
|
||||
m = re.search(re.escape(selector) + r" \{([^}]*)\}", css)
|
||||
assert m, selector
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_표_폭_인라인_변수_css_width_변수():
|
||||
ts = DT_TS.read_text("utf-8")
|
||||
assert '<table class="ui-data-table__table" style="--dt-w:${x}px">' in ts
|
||||
assert 'style="width:${x}px"' not in ts # 인라인 width 가 페이지 css 를 이기지 않음
|
||||
body = _rule(DT_CSS.read_text("utf-8"), ".ui-data-table__table")
|
||||
assert "width: var(--dt-w);" in body
|
||||
assert "table-layout: fixed;" in body
|
||||
|
||||
|
||||
def test_거리이정표_표_틀_폭_전체():
|
||||
css = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Milestone.css").read_text("utf-8")
|
||||
body = _rule(css, ".b08-milestone > .ui-data-table .ui-data-table__table")
|
||||
assert "width: 100%;" in body # 변수 규칙(0,1,0)보다 선택자가 구체적
|
||||
|
||||
|
||||
def test_입력_칸_테두리_옅게_outline():
|
||||
css = DT_CSS.read_text("utf-8")
|
||||
body = _rule(css, ".ui-data-table__table td.is-can-edit")
|
||||
m = re.search(r"var\(--color-accent\) (\d+)%", body)
|
||||
assert m and 25 <= int(m.group(1)) <= 30 # 예전 60% → 옅게
|
||||
assert "outline: 1px solid" in body and "box-shadow" not in body
|
||||
assert '"is-can-edit"' in DT_TS.read_text("utf-8")
|
||||
|
||||
|
||||
def test_구조물_조합_미지정_회색():
|
||||
css = (ROOT / "B05_Profile" / "B05_Profile_UI_Style_Structures.css").read_text("utf-8")
|
||||
body = _rule(css, ".b05-structure__summary-name:not(.is-assigned)")
|
||||
assert "border-color: var(--color-text-muted);" in body
|
||||
assert "--color-text-muted" in body.split("background:")[1]
|
||||
assert "--color-warning" not in body
|
||||
@@ -0,0 +1,115 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 259 — B06 [상세] 영역 · 길이 · 표 강조.
|
||||
|
||||
못박는 것
|
||||
- 259-1 공용 팬(`A00_Common/b_svg_zoom_pan.ts`) — 가운데 단추 팬 뒤 왼쪽 누름 click 이 안 삼켜짐 ·
|
||||
거리이정표 꼴(빈 곳 왼쪽 끌기 팬)은 끌기 끝 click 만 막음.
|
||||
- 259-1 암 경계선 — 설계선과 만나는 점에서 나뉨 · 고름 값 · 저장 꼴 · 서버 `pick_lines` 거울 ·
|
||||
암 프리셋 아니면 다시 고름.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_259_detail_region.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B06_Section.B06_Section_Schema import CrossSectionPatch
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import cell_value, pick_lines
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
LINE = [(-6.0, 104.0), (-2.0, 98.0), (2.0, 98.0), (6.0, 104.0)]
|
||||
DESIGN = {
|
||||
"design_elevation_m": 98.0,
|
||||
"geometry_preset": "rock",
|
||||
"rock_boundary_offset_m": -1.0,
|
||||
"road_edges": {
|
||||
"left": {"offset_m": 2.0, "elevation_m": 98.0},
|
||||
"right": {"offset_m": -2.0, "elevation_m": 98.0},
|
||||
},
|
||||
"design_line": [{"offset_m": x, "elevation_m": y} for x, y in LINE],
|
||||
}
|
||||
SAMPLES = [{"offset_m": x, "elevation_m": 100.0, "valid": True} for x in (-10, -5, 0, 5, 10)]
|
||||
AXES = {
|
||||
"widthPx": 600,
|
||||
"heightPx": 300,
|
||||
"pixelsPerMeter": 10,
|
||||
"maxOffset": 14,
|
||||
"displayMax": 110,
|
||||
"elevationMid": 100,
|
||||
"exaggeration": 2,
|
||||
}
|
||||
ROCK_X = 2 + 4 / 6 # 경사(4 : 6)가 암 경계선 99 m 와 만나는 offset
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_259_detail_region.cjs")],
|
||||
input=json.dumps({"design": DESIGN, "samples": SAMPLES, "axes": AXES}),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
cwd=os.fspath(ROOT),
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
# ── 259-1 공용 팬 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_가운데_단추_팬_뒤_왼쪽_누름_통과(out):
|
||||
assert out["panMiddle"] == {"firstClickStopped": False, "secondClickStopped": False}
|
||||
|
||||
|
||||
def test_거리이정표_왼쪽_끌기_팬은_끌기_click_만_막음(out):
|
||||
assert out["panLeft"] == {"afterDragStopped": True, "nextClickStopped": False}
|
||||
|
||||
|
||||
# ── 259-1 암 경계선 나눔 · 고름 ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_암_경계선이_설계선과_만나는_점에서_나뉨(out):
|
||||
rock = [p for p in out["pieces"] if p["line"] == "rock"]
|
||||
assert len(rock) == 3
|
||||
ends = sorted(round(p["points"][0][0], 4) for p in rock)
|
||||
assert ends == pytest.approx([-10.0, -ROCK_X, ROCK_X], abs=1e-3)
|
||||
# 설계 경사도 암 경계선에서 잘림(경사 끝 · 암 교점 · 원지반 교점)
|
||||
design_ends = {
|
||||
round(q[0], 3)
|
||||
for p in out["pieces"]
|
||||
if p["line"] == "design"
|
||||
for q in (p["points"][0], p["points"][-1])
|
||||
}
|
||||
assert round(ROCK_X, 3) in design_ends
|
||||
|
||||
|
||||
def test_암_경계선_고름_값_저장_꼴(out):
|
||||
length = 2 * ROCK_X
|
||||
assert out["rockLength"] == pytest.approx(length, abs=1e-3)
|
||||
assert out["rockLabel"] == f"암 경계선 길이 {length:.2f}m\n수평 {length:.2f}m · 높이 0.00m"
|
||||
source = out["rockSource"]
|
||||
assert source["src"] == "length" and source["pieces"][0]["line"] == "rock"
|
||||
patch = CrossSectionPatch(chainage_m=40.0, detail_table={"cells": {"fence": source}})
|
||||
assert patch.model_dump()["detail_table"]["cells"]["fence"]["pieces"][0]["line"] == "rock"
|
||||
|
||||
|
||||
def test_암_경계선_서버_거울(out):
|
||||
number, _text, again = cell_value(out["rockSource"], DESIGN, SAMPLES)
|
||||
assert not again and number == pytest.approx(out["rockLocal"], abs=1e-6)
|
||||
assert pick_lines(DESIGN, SAMPLES)["rock"] == [[(x, 99.0) for x in (-10, -5, 0, 5, 10)]]
|
||||
# 토사 프리셋이면 암 경계선 없음 → 다시 고름
|
||||
soil = {**DESIGN, "geometry_preset": "soil"}
|
||||
assert pick_lines(soil, SAMPLES)["rock"] == []
|
||||
assert cell_value(out["rockSource"], soil, SAMPLES) == (None, None, True)
|
||||
@@ -108,7 +108,7 @@ def test_155_1_글자_14px_열_폭_같은_비율(r):
|
||||
assert abs(r["scale"] - 14 / 12) < 1e-9
|
||||
w = r["widths"]
|
||||
assert '<col style="width:112px"><col style="width:98px"><col style="width:140px">' in w
|
||||
assert 'style="width:350px"' in w # 표 폭 = 112 + 98 + 140
|
||||
assert 'style="--dt-w:350px"' in w # 표 폭 = 112 + 98 + 140 (258-1 · css 변수)
|
||||
assert 'class="is-frozen is-edge" style="left:112px"' in w # 둘째 고정 열 left 도 같이
|
||||
assert "font-size: var(--text-body-sm);" in r["css"]
|
||||
assert "--dt-row-h: 28px;" in r["css"]
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
}
|
||||
|
||||
.ui-data-table__table {
|
||||
/* 258-1 — 표 폭 = 열 합(--dt-w · 인라인) · 페이지 css 가 width 로 덮음 */
|
||||
width: var(--dt-w);
|
||||
table-layout: fixed;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
@@ -175,6 +177,12 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 고칠 수 있는 칸(PLAN 258-2 · editOnDouble) — 글일 때도 옅은 입력 칸 테두리(outline 안쪽 · 줄 고름 box-shadow 와 안 겹침) */
|
||||
.ui-data-table__table td.is-can-edit {
|
||||
outline: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-canvas));
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.ui-data-table__input:focus {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: -2px;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* 나무(PLAN 235-1 · tree) = 접히지 않는 나무 안내선(├ is-tee · └ is-end · │ is-bar) + 폴더 · 파일 아이콘(공용 나무 꼴)
|
||||
* 더블클릭 입력(PLAN 235-1 · editOnDouble) = 고칠 칸도 글로 그림 · 더블클릭 · t.edit(줄) = 그 칸만 글 상자 ·
|
||||
* Enter(조합 끝난 뒤) · 초점 떠남 = 입력 끝(onEdit) — 한 번 누름 = 줄 고름 · 끌기
|
||||
* 고칠 수 있는 칸 = 글일 때도 칸에 입력 칸 테두리 class `is-can-edit`(PLAN 258-2 · 옅은 outline) ·
|
||||
* 칸 누른 뒤 Enter · F2 = 그 칸 입력 시작(PLAN 243-1 · keyEdit false = 표 밖이 맡음)
|
||||
* 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만
|
||||
* 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c)
|
||||
@@ -183,7 +184,7 @@ function rowHtml(
|
||||
more: RowMore = {},
|
||||
): string {
|
||||
let tr = "";
|
||||
const { lv, spans } = more;
|
||||
const { lv, spans, can } = more;
|
||||
for (let j = 0; j < cols.length; j++) {
|
||||
const key = cols[j].key;
|
||||
const span = spans?.[j] ?? 1;
|
||||
@@ -209,6 +210,7 @@ function rowHtml(
|
||||
frz && left[j + 1] == null && "is-edge",
|
||||
empty && "is-empty",
|
||||
cellClass?.(key),
|
||||
can?.(key) && "is-can-edit",
|
||||
lvc.trim(),
|
||||
span > 1 && "is-merged",
|
||||
]
|
||||
@@ -241,6 +243,8 @@ function rowHtml(
|
||||
interface RowMore {
|
||||
lv?: { key: string; n: number; node: boolean; tree?: string };
|
||||
spans?: number[];
|
||||
/** 고칠 수 있는 칸인지(editOnDouble · 글로 그린 칸에 `is-can-edit` 표시 · 258-2) */
|
||||
can?: (key: string) => boolean;
|
||||
}
|
||||
|
||||
/** 칸 → 열 번호 — 병합 표는 칸이 빠져 cellIndex 가 밀리므로 data-c */
|
||||
@@ -356,6 +360,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
|
||||
{
|
||||
...(o.level ? { lv: { key: o.level.key, n: lvN, node, tree: tree?.[i] } } : {}),
|
||||
...(spans ? { spans: spans[i - from] } : {}),
|
||||
...(o.editOnDouble && o.editable ? { can: (key: string) => o.editable!(i, key) } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -363,7 +368,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
|
||||
const foot = o.total ? `<tfoot>${rowHtml(cols, fmts, left, o.total, false, blank)}</tfoot>` : "";
|
||||
const cap = o.caption ? `<caption>${esc(o.caption)}</caption>` : "";
|
||||
return (
|
||||
`<table class="ui-data-table__table" style="width:${x}px">${cap}<colgroup>${colgroup}</colgroup>` +
|
||||
`<table class="ui-data-table__table" style="--dt-w:${x}px">${cap}<colgroup>${colgroup}</colgroup>` +
|
||||
`<thead>${headHtml(cols, left)}</thead><tbody>${body}</tbody>${foot}</table>`
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user