Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-11-02 23:29:24 +09:00
12 changed files with 183 additions and 9 deletions
+9 -2
View File
@@ -29,6 +29,8 @@ export interface SheetLine {
장비?: boolean | null;
기준단위?: string | null;
환산근거?: string;
/** (753-1) 옵션으로 빠진 줄 — 그리지 않음(소계 · 쓰는 입력 칸은 그대로) */
옵션꺼짐?: boolean;
/** 714-1b 장비 줄 펼침 — 부른 기계경비 로직의 시간당 줄(조종원 · 주연료 · 잡재료비 · 기계손료) */
펼침?: SheetUnfold | null;
}
@@ -270,6 +272,10 @@ function headRows(
const subtotal = (g: SheetGroup): HTMLElement =>
createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true));
/** 옵션으로 빠진 줄(753-1)은 안 그림 — 장비 · 하위 몫 줄은 몫이 다 빠졌을 때만 */
const shown = (l: SheetLine | SheetMerged): boolean =>
"몫" in l ? l.몫.some((x) => !x.줄.옵션꺼짐) : !l.옵션꺼짐;
/** 계산서 줄들 — 부르는 쪽 세로 칸(flex column)에 그대로 붙임 */
export function calcSheetRows(o: SheetOptions): HTMLElement[] {
const rows: HTMLElement[] = [];
@@ -278,7 +284,8 @@ export function calcSheetRows(o: SheetOptions): HTMLElement[] {
for (const b of bundleLines(o.묶음)) {
const name = bundleName(b.묶음);
rows.push(...headRows(name, b.쓰는입력, o, specs, b.기준단위, b.환산근거));
for (const line of b.줄) rows.push(...("몫" in line ? mergedRows(line) : [lineRow(line)]));
for (const line of b.줄.filter(shown))
rows.push(...("몫" in line ? mergedRows(line) : [lineRow(line)]));
rows.push(createCalcFormulaRow(name, null, out(b.소계, true, 1)));
}
rows.push(...o.묶음.map(subtotal)); // 맨 아래 비목 소계(서버 끝수 그대로) — 장비 노무는 여기서 인력 노무와 더해짐
@@ -286,7 +293,7 @@ export function calcSheetRows(o: SheetOptions): HTMLElement[] {
for (const g of o.묶음) {
const uses = [...new Set(g.줄.flatMap((l) => l.쓰는입력 ?? []))];
rows.push(...headRows(g.비목, uses, o, specs));
rows.push(...g.줄.map((line) => lineRow(line)), subtotal(g));
rows.push(...g.줄.filter(shown).map((line) => lineRow(line)), subtotal(g));
}
}
rows.push(createCalcFormulaRow(o.sumLabel, null, out(o.계, true)));
+18
View File
@@ -52,6 +52,22 @@ import { designSignature, markDesignClean } from "./design_leave";
import { routeGradeTouch } from "./design_pipeline_page";
import type { GradeTouch } from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
/** [저장] · [확정] 막음(755-3) — 글을 돌려주면 알림하고 안 냄 · `root` 가 화면에서 빠지면 풂. */
const saveHolds: Array<{ root: HTMLElement; hold: () => string | null }> = [];
export function holdDesignSave(root: HTMLElement, hold: () => string | null): void {
saveHolds.push({ root, hold });
}
function heldSave(): string | null {
for (let index = saveHolds.length - 1; index >= 0; index -= 1) {
if (!saveHolds[index].root.isConnected) saveHolds.splice(index, 1);
}
for (const { hold } of saveHolds) {
const why = hold();
if (why) return why;
}
return null;
}
export interface DesignSaveContext {
projectId: string;
routeId: () => number | null;
@@ -249,6 +265,8 @@ export async function saveDesign(
const confirm = options.confirm ?? false;
const routeId = ctx.routeId();
if (routeId === null) return false;
const held = heldSave();
if (held) return (showToast(held, "warning"), false);
saving.add(ctx.projectId);
let locked = confirm;
if (locked) showLoadingOverlay();
+26 -2
View File
@@ -2,7 +2,9 @@
계산 자리 ③ 서버 단독 · 읽기만(파일 안 만듦). 원본 = 올린(받은) 노선 파일 꼭짓점을 사업지 좌표로 ·
사용 범위(`projects.route_start_m · route_end_m`) · 지표면 트림까지 — 체인 예정노선
(`load_design_route`)과 같은 차례에서 **조밀화만 뺌**. 고친 꼭짓점은 `POST /route/follow` 가
(`load_design_route`)과 같은 차례에서 **조밀화만 뺌**. 끝 = 체인이 자른 `expected_route.csv`
양끝(755-3 · 지표면이 그 뒤 넓어져도 화면 예상노선 · 저장 csv 와 같은 길이).
고친 꼭짓점은 `POST /route/follow` 가
같은 간격(`spacing_m`)으로 조밀화한 뒤 추적 → 안 고친 원본 = 체인 초기선과 거리 0(`test_635_2`).
GET /api/projects/{id}/route/plan/source
@@ -13,15 +15,23 @@
from __future__ import annotations
import asyncio
import math
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from shapely.geometry import LineString, Point
from B05_Profile.B05_Profile_Router_Replan import _PROJECT_PATH_MISSING, _project_paths
from common_util.common_util_route_geometry import load_design_route, trim_route_to_surface
from common_util.common_util_route_geometry import (
clip_route_by_chainage,
expected_route_csv_path,
load_design_route,
read_planned_route_csv,
trim_route_to_surface,
)
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_surface_sampler import build_surface_sampler
from config.config_db import get_db_pool
@@ -57,9 +67,23 @@ def source_vertices(
sampler = None
if sampler is not None:
points = trim_route_to_surface(points, sampler)
points = clip_to_expected(root, points)
return [[float(x), float(y)] for x, y in points] if len(points) >= 2 else []
def clip_to_expected(root: Path, points: list[tuple[float, float]]) -> list[tuple[float, float]]:
"""체인이 자른 예정노선 파일 양끝에서 자름(755-3) — 파일 없음 · 2점 미만 = 그대로."""
path = expected_route_csv_path(root)
stored = read_planned_route_csv(path) if path.is_file() else None
if stored is None or len(stored.vertices) < 2 or len(points) < 2:
return points
line = LineString(points)
ends = [line.project(Point(v.x, v.y)) for v in (stored.vertices[0], stored.vertices[-1])]
clipped = clip_route_by_chainage(points, min(ends), max(ends))
# 파일 좌표 반올림(0.1 mm) = 끝이 꼭짓점 바로 앞에 떨어짐 — 1 mm 안 겹친 점은 하나만
return [p for i, p in enumerate(clipped) if not i or math.dist(p, clipped[i - 1]) > 1e-3]
@router.get("/{project_id}/route/plan/source", response_model=None)
async def read_plan_source(project_id: UUID) -> dict[str, Any] | JSONResponse:
paths = await _project_paths(project_id)
@@ -11,11 +11,14 @@
* 끌 때 한 번 = 끊겨 있으면 알림하고 안 끔 · 바뀌었으면 확인창 → `POST /route/follow`(저장 0 · 폴리라인 두 단계) →
* `onFollow(평면, 예정노선)` = 편집기 한 걸음 → 페이지 [저장] = 손 편집과 같은 길 + 초안 `expected`(`expected_route_edit.csv`).
* [받은 노선으로] = 받은 꼭짓점으로 다시 추적 · 초안 `expected` = [](고친 예정노선 지움) — 한 걸음 · 페이지 [초기화] 는 그대로.
* 켠 채 판을 고쳤으면(755-3) = [저장] · [확정] 막고 알림(`holdDesignSave`) · 떠남 = 공용 떠남 물음 · 탭 닫기 = 브라우저 물음.
* 예정노선 세 벌 = 받은 노선(`expected_route.csv` · 안 덮음 · 고쳤으면 옅은 점선) · 저장본(`expected_edited`) · 이번 고침(`edit`).
* 같은 예정노선 다른 세트(635-4) = 흐린 겹 보기만(`B05_Profile_UI_PlanContext`) · 그 끝 = 긋기 · 끌기 붙을 자리 · 벗어나면 안내 한 줄.
* ========================================================================== */
import { keyIs } from "../A00_Common/key_registry";
import { guardProjectLeave } from "../A00_Common/b_project_switch";
import { holdDesignSave } from "../A00_Common/design/design_save";
import { listenWhileAlive } from "@ui/ui_template_window_watch";
import { createButton, showToast } from "@ui/ui_template_elements";
import { askConfirm } from "@ui/ui_template_modal";
@@ -42,6 +45,8 @@ import { flattenServerPlan, type FlattenedPlan } from "./logic/B05_Profile_UI_Ro
type Vertex = [number, number];
const OFF_HINT = "「예정노선 수정」을 켜면 지도에서 예정노선을 고칩니다.";
const HELD =
"예정노선 수정 중 — 「예정노선 수정」을 끈 뒤 [저장]합니다(켠 동안 고친 판은 끌 때 평면선에 들어감).";
/** `POST /route/follow` 응답 — GET `/route/plan` 칸 이름(626-2). */
type FollowResponse = Pick<RoutePlanResponse, "planned" | "nodes" | "curves" | "node_edits">;
@@ -132,6 +137,14 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
return { lines: base.length > 1 ? [{ id: "main", vertices, branch: null }] : [], pieces: [] };
};
const measuring = (): boolean => open && params.measuring();
/** 켠 채 판을 고침 — [저장] · 떠남이면 잃음(755-3). */
const pending = (): boolean => open && undoStack.length > 0;
holdDesignSave(canvas, () => (pending() ? HELD : null));
guardProjectLeave(canvas, {
dirty: pending,
save: async () => (showToast(HELD, "warning"), false),
message: HELD,
});
// 속성 판 경위도 = 서버 변환(635-5)
const coords = createPlanCoords(params.projectId, () => kit.props.render());
@@ -158,7 +158,8 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
onInput ? specs.find((x) => x.이름 === name) : undefined;
// 737-2 비목마다 구분 상자 — 맨 위 옵션 칸 · 아래 두 칸 식 줄들 · 소계
const boxes = text.groups.map((g) => {
const lines = g.줄.map((line, i) => {
const visible = g.줄.filter((l) => !l.옵션꺼짐); // 753-2 옵션으로 빠진 줄은 안 그림
const lines = visible.map((line, i) => {
const pair = String(line.짝 ?? `${g.비목}${i}`);
const open = ctx.open?.(line);
return el("div", {
@@ -615,9 +615,12 @@ function boxCard(
const notes = [...new Set(lines.map((e) => e.textLine?.환산근거).filter(Boolean))];
const { root, tbody } = tableShell(COLS, { density: "data" });
root.classList.add("m01lab__ho");
// 753-2 옵션으로 빠진 줄은 안 그림(옵션 칸 · 소계는 그대로 — 옵션을 바꾸면 보이는 줄이 바뀜)
const off = (e: Entry): boolean => !!(e.line?.옵션꺼짐 || e.textLine?.옵션꺼짐);
const rows = b.줄.filter((r) => !("몫" in r ? r.몫.every(off) : off(r)));
tbody.append(
...(b.줄.length
? b.줄.flatMap((r) => ("몫" in r ? mergedRows(r, ctx) : [lineRow(r, ctx)]))
...(rows.length
? rows.flatMap((r) => ("몫" in r ? mergedRows(r, ctx) : [lineRow(r, ctx)]))
: [emptyRow(COLS, tl("Empty_Group"))]),
);
const sub = tableRow(COLS, [
@@ -105,6 +105,8 @@ export interface CalcLine {
까닭?: string;
/** (M02 744-1) 둘째 기초 세부 줄 — `수량`은 M01 원문 · 이 칸은 1 기준단위 몫 비목(금액 · 비목은 머리 수량 곱한 상자 몫) */
원문?: { 비목?: Record<string, number | null> };
/** (753-1) 옵션으로 빠진 줄 — 화면은 안 그림 */
옵션꺼짐?: boolean;
}
/** % 덧줄 표기(149-4) — 율 있으면 수량 = 율 · 단위 % · 이름 뒤 규격 괄호 · 금액은 그대로(M01 · M02 한 벌) */
@@ -160,6 +162,8 @@ export interface TextLine {
환산근거?: string;
/** (M02 750-1) SK 셈 답 줄 cid — 상세 줄과 이음 */
cid?: string;
/** (753-1) 옵션으로 빠진 줄(수량 0 이 만약 갈래 탓) — 화면은 그 줄을 안 그림(금액 · 차례 그대로) */
옵션꺼짐?: boolean;
}
/** `POST /text` 답 — 서버가 만든 읽는 식 줄(화면은 그대로 찍음) */
export type TextAnswer =
@@ -262,7 +262,8 @@ export function answerView(answer: CalcAnswer, draft: boolean, money: boolean):
}
const parts: HTMLElement[] = [...note];
if (answer.lines) {
const rows = answer.lines.map((line) =>
const visible = answer.lines.filter((line) => !line.옵션꺼짐); // 753-2 옵션으로 빠진 줄은 안 그림
const rows = visible.map((line) =>
el("tr", {
children: [
el("td", {
@@ -90,6 +90,8 @@ export interface SkLine {
원문?: { 수량: number | null; 금액: number | null; 비목?: Record<string, number | null> };
/** 재료 줄이 잡은 품목(시험 계산 재료 칸 · 줄 모달) */
품목?: CalcLine["품목"];
/** (753-1) 옵션으로 빠진 줄 — 화면은 안 그림 */
옵션꺼짐?: boolean;
}
export interface SkMat {
@@ -152,6 +154,8 @@ export interface CoverRow {
sub: { k: number; cid: string; scale: number } | null;
/** 화면배(728-2) — 문서 품 = 화면 품 × t · `was` = 문서 몫 */
t: number;
/** (753-2) 옵션으로 빠진 줄 — 계산서에서 안 그림 */
off: boolean;
}
/* ── 순수 셈 ── */
@@ -238,7 +242,13 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR
금액: number | null;
},
parts: Record<string, number>,
extra: { 묶음?: string | null; 율?: number; apart?: boolean; 바탕값?: SkLine["바탕값"] },
extra: {
묶음?: string | null;
율?: number;
apart?: boolean;
바탕값?: SkLine["바탕값"];
옵션꺼짐?: boolean;
},
): CoverRow => {
const hit = ADD_RE.exec(cid);
const added = hit ? Number(hit[1]) : null;
@@ -282,6 +292,7 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR
apartPick: one?.종류 === "재료",
sub: at ? { ...at, scale: Number(owner?.수량 ?? 1) } : null,
t: pct || at ? 1 : t, // 세부 줄 품 = M01 원문 그대로(744-1) — 화면배 안 곱함
off: !!extra.옵션꺼짐,
};
};
for (const l of flatLines(ans)) rows.push(row(l.cid, l, partsOf(l), l));
@@ -421,6 +432,7 @@ export function sheetGroups(rows: CoverRow[]): SheetGroup[] {
금액: amount,
묶음: r.묶음,
장비: entries.length > 1 && (r.sub?.cid ?? r.cid).startsWith("logic:"),
...(r.off ? { 옵션꺼짐: true } : {}),
});
}
}
@@ -133,6 +133,31 @@ def test_plan_source_skips_densify_keeps_trim(tmp_path: Path, monkeypatch) -> No
assert asyncio.run(source.read_plan_source(uuid4())).status_code == 404
def test_plan_source_clips_to_expected_csv_ends(tmp_path: Path, monkeypatch) -> None:
"""755-3 — 지표면이 넓어져 트림이 덜 잘라도 체인 예정노선 파일 양끝에서 자름(같은 길이)."""
monkeypatch.setattr(
source,
"load_design_route",
lambda *a: SimpleNamespace(vertices=[SimpleNamespace(x=x, y=y) for x, y in SPARSE]),
)
monkeypatch.setattr(source, "build_surface_sampler", lambda *a: object())
monkeypatch.setattr(source, "trim_route_to_surface", lambda points, sampler: points)
surface = {"source_filter": "classification", "method": "dtm", "smooth": True}
dense = densify_route(SPARSE[3:30], source.SPACING_M)
write_route_csv(expected_route_csv_path(tmp_path), [{"x": x, "y": y} for x, y in dense])
got = source.source_vertices(tmp_path, surface, None)
def length(points):
return sum(math.dist(a, b) for a, b in zip(points, points[1:]))
assert abs(length(got) - length(dense)) < 1e-3 # 파일 좌표 0.1 mm 반올림
assert math.dist(got[0], SPARSE[3]) < 1e-3 and math.dist(got[-1], SPARSE[29]) < 1e-3
assert len(got) == 27 # 끝 = 꼭짓점 바로 앞 · 겹친 점 없음
# 파일 없음 = 그대로
expected_route_csv_path(tmp_path).unlink()
assert len(source.source_vertices(tmp_path, surface, None)) == len(SPARSE)
MAIN = {
"id": "main",
"vertices": [[200000.0, 450000.0], [200100.0, 450000.0], [200200.0, 450000.0]],
@@ -41,3 +41,55 @@ def test_752_1_source_panel_only_on_sangeun_screen():
assert "source" not in _ts("M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts")
detail = _ts("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts")
assert "opt.source ? createSangeunSide()" in detail and "side?.set(" in detail
def test_753_2_option_off_flag_reaches_sheet_and_views():
"""753-2 옵션으로 빠진 줄 — SK 줄 `옵션꺼짐` → 표 줄 `off` → 계산서 줄 `옵션꺼짐`(공용 계산서 · 상세 ·
텍스트 수식 · 시험 계산이 안 그림) · 소계 · 금액은 그대로."""
import json
import re
import shutil
import subprocess
import pytest
if shutil.which("node") is None:
pytest.skip("node 가 없음")
m02 = ROOT / "M02_MasterTemplete"
strip = lambda p: re.sub(r"^import .*$", "", p.read_text("utf-8"), flags=re.M) # noqa: E731
src = "\n".join(
[
strip(m02 / "logic" / "M02_Born_Tag.ts"),
strip(m02 / "logic" / "M02_Sangeun_Undo.ts"),
strip(m02 / "logic" / "M02_Sangeun_Cover_Model.ts"),
"""
const line = (cid, 이름, 수량, 금액, 꺼짐) => ({ cid, 이름, 단위: "개", 수량, 단가: 100, 금액,
비목: { 재료비: 금액 }, 묶음: "재료", ...(꺼짐 ? { 옵션꺼짐: true } : {}) });
const ans = { 기준: null, 짝잃음: [], 바탕판: "x", 정체: ["material:A#0", "material:B#0"],
줄: [line("material:A#0", "폼타이 12.7", 0, 0, true), line("material:B#0", "합판", 1, 100, false)] };
const rows = rowsOf(ans, null, {});
const groups = sheetGroups(rows);
console.log(JSON.stringify({ off: rows.map((r) => r.off),
sheet: groups.map((g) => [g.비목, g.소계, g.줄.map((x) => [x.이름, x.옵션꺼짐 ?? false, x.금액])]) }));
""",
]
)
out = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", src],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert out.returncode == 0, out.stderr
got = json.loads(out.stdout)
assert got["off"] == [True, False]
assert got["sheet"] == [["재료비", 100, [["폼타이 12.7", True, 0], ["합판", False, 100]]]]
sheet = (ROOT / "A00_Common" / "calc_sheet.ts").read_text("utf-8")
assert "b.줄.filter(shown)" in sheet and "g.줄.filter(shown)" in sheet
ho = (ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Ho.ts").read_text("utf-8")
assert "옵션꺼짐" in ho
assert "옵션꺼짐" in (
ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Formula.ts"
).read_text("utf-8")
assert "옵션꺼짐" in (ROOT / "M01_MasterData" / "M01_MasterData_UI_Logic_Calc.ts").read_text(
"utf-8"
)
@@ -3,6 +3,7 @@
- 755-1 켠 동안 = 판만 고침 · 끊김 허용(가운데 묶음 지움) · 이 판 사진첩(Ctrl+Z · 도구줄 단추) ·
끌 때 한 번 = 끊겼으면 알림하고 안 끔 · 바뀌었으면 `/route/follow` 한 걸음 · [잇기] = B05 사용.
- 755-2 켠 동안 재기 = D03 재기 겹 · 받은 노선 = 분홍 점선 + 검은 테 · 켬 설명 걷음 · 위치 칸 걷음.
- 755-3 켠 채 판 고침 = [저장] · [확정] 막고 알림 · 떠남 = 공용 떠남 물음(탭 닫기 = 브라우저 물음).
./venv/Scripts/python.exe -m pytest resources/tester/test_755_expected_flow.py -q
"""
@@ -82,3 +83,16 @@ def test_받은_노선_색_설명_위치_걷음():
assert re.search(r'key: "site",[^}]*d03: "use"', table, re.S) # D03 그대로
theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8")
assert "--draw-map-route-3:" in theme
def test_켠_채_저장_떠남_알림():
body = _body().replace("\r\n", "\n")
assert "const pending = (): boolean => open && undoStack.length > 0;" in body
assert "holdDesignSave(canvas, () => (pending() ? HELD : null));" in body
assert "guardProjectLeave(canvas, {\n dirty: pending," in body
save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text("utf-8")
save = save.replace("\r\n", "\n")
head = save[save.index("export async function saveDesign(") :]
held = head.index("const held = heldSave();")
assert held < head.index("saving.add(ctx.projectId);") # 막음 = 저장 줄 앞 · 가림막 없음
assert held < head.index("showLoadingOverlay")