Merge remote-tracking branch 'origin/dev' into sub_laptop_1
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();
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 로 못 씀
|
||||
@@ -233,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()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -251,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
|
||||
@@ -281,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")
|
||||
@@ -396,6 +473,32 @@ 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"
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
* (뒤에서 · 화면 안 잠금) · 사용자 그림 우선 · 실패 = 까닭 한 줄(알림 · 경로 탭) · 좌표는 서버도 안 남김
|
||||
* 길찾기 = 연결선 한 구간씩(244-13 · `findSegmentKm` · 거래처 · 기준점 → to(기준점 · 시점) · 254) ·
|
||||
* 자동 그림 = 거래처 쪽 구간 길 선 · 표지 둘(거래처 · 기준점 또는 시점)
|
||||
* 장소 그림(256-1) = 상자 좌표가 정해지면 `capturePlace` → 서버 캡처 `place/<상자 id>.png` ·
|
||||
* [저장] 뒤엔 서버가 바뀐 상자만 다시(다음 읽기에 보임) · `placeImageKey` · `placeImageBlobUrl`
|
||||
* 기준점 여럿(254) = `bases[]` · 길 선 표 키 = 거래처 · 기준점 id · 옛 꼴 캐시 = 읽을 때 새 꼴(`migrateMilestone`)
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -42,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 = {
|
||||
@@ -227,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());
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}`];
|
||||
|
||||
@@ -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));
|
||||
@@ -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
|
||||
|
||||
@@ -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"] == [] # 다른 탭(일위대가표) = 단추 없음
|
||||
|
||||
@@ -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,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,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,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)
|
||||
Reference in New Issue
Block a user