Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-10-19 08:15:05 +09:00
5 changed files with 324 additions and 11 deletions
+2
View File
@@ -424,6 +424,8 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
done: async (isConfirm) => {
markDesignClean(shared.projectId); // 떠남 확인 — 지금 초안이 저장분(346-4)
if (isConfirm) {
// 서버가 정본을 다시 씀 — 다음 페이지(B07)가 브라우저 셈 · 옛 세션 사본 말고 정본을 받게(470 N1)
invalidateSectionDetail(shared.projectId);
showToast(L("B06_Profile_Confirm_Success"), "success");
goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]);
return;
@@ -298,12 +298,13 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
// 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다
// (2026-09-12 일원화). 세부유역은 관 초안을 쓰면 배수유역도가 뒤에서 다시 나눈다(298-1).
onPipeUpdate: (fromChainageM, toChainageM, attributes) => {
// 값만 고치면 폼이 옛 측점을 그대로 돌려줌(안 고친 칸) — 0.005m 넘으면 옮긴 것(80.17 → 80 도 · 234-1)
if (Math.abs(fromChainageM - toChainageM) > 0.005) movePipeTo(fromChainageM, toChainageM);
// 값만 고치면 폼이 옛 측점을 그대로 돌려줌(안 고친 칸) — 0.005m 넘으면 옮긴 것(80.17 → 80 도 · 234-1) ·
// 옮긴 뒤 옵션 · 캐시 얹기는 옮긴 자리(맞춘 정수 m)로 찾음(옛 자리 = 비운 그림 · 470 N2)
const moved = Math.abs(fromChainageM - toChainageM) > 0.005;
const at = (moved ? movePipeTo(fromChainageM, toChainageM) : null) ?? fromChainageM;
if (attributes.summary_item !== undefined || attributes.group_items !== undefined) {
// 옮긴 뒤 자리(toChainageM)에서 찾는다 — 안 옮겼으면 from 과 0.51m 안이라 같은 관이다.
const mine = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - toChainageM) < PIPE_MATCH_M,
(entry) => Math.abs(entry.chainage_m - at) < PIPE_MATCH_M,
);
const before = mine ? JSON.stringify(linkFields(mine)) : "";
if (mine && attributes.summary_item !== undefined)
@@ -322,14 +323,12 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
return;
}
// 값은 관 목록 초안(가게 pipes) 하나로 나간다 — 조정 예약(culvertopt)에 따로 담으면 비운 칸이
// [저장] 때 옛 값으로 되살아난다. 옮기기 전 자리로 찾는다(저장 시점 목록 기준).
const hit = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
);
// [저장] 때 옛 값으로 되살아난다.
const hit = pipeFacilities.find((entry) => Math.abs(entry.chainage_m - at) < PIPE_MATCH_M);
const was = hit?.facility ?? "pipe";
const facility = attributes.facility ?? was;
// 종류를 바꿈(54-4) — 옛 종류 그림 캐시에 새 종류 칸을 얹지 않음 · 옛 칸은 안 이음
if (facility === was) deps.applyPipeOptions?.(fromChainageM, patch);
if (facility === was) deps.applyPipeOptions?.(at, patch);
if (hit) {
hit.options =
mergeFacilityOptions(
@@ -533,10 +532,11 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
}
/** 관 옮기기 — 폼의 측점 칸과 종단 끌기가 같은 길을 탄다. 재분할은 [저장]·[확정] 뒤. */
function movePipeTo(fromChainageM: number, toChainageM: number): void {
/** 관을 옮김 — 옮긴 자리(정수 m) · 그 자리 관이 없으면 null. */
function movePipeTo(fromChainageM: number, toChainageM: number): number | null {
if (!deps.projectId) {
showToast(PIPE_MOVE_GUIDE, "error");
return;
return null;
}
const to = snapStructureStation(toChainageM); // 정수 m · 일반 측점 붙기(234-1)
const hit = pipeFacilities.find(
@@ -555,6 +555,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
section.setPipeFacilities(pipeFacilities);
pushMarks();
showToast(PIPE_MOVE_NOTICE, "success");
return hit ? to : null;
}
/** 관 빼기 — 목록·폼·종단 우클릭이 같은 길을 탄다. 정본은 [저장]·[확정]에서 나간다. */
@@ -0,0 +1,254 @@
/* 466 덧(470 N2) 헬퍼 — 진짜 `createB06StructuresPanel` 의 B06 폼 관 고침(`onPipeUpdate`)이 관을 0.51 m 넘게
* 옮기며 옵션도 고칠 때 옵션이 옮긴 관(초안)에 붙고 캐시 얹기도 새 자리인지. 가짜 모듈 틀 = helper_165_pavement_flow.cjs.
* 표준출력 JSON 한 벌 · 짝 = test_466_pipe_move_options.py. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1)
const ROOT = path.join(__dirname, "..", "..");
function loadTs(file, stubs = {}) {
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const localRequire = (request) => {
if (CATALOG.isCatalogRequest(request)) return CATALOG;
if (stubs[request]) return stubs[request];
throw new Error(`stub 없음: ${request}`);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
// 세션(structure-pick) — 메모리 한 벌
const store = new Map();
const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), {
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
},
});
const PID = "p165";
const TYPES = [
{ type_id: "pavement_concrete", group: "G" },
{ type_id: "hazard_sign", group: "E" },
{ type_id: "pipe", group: "A", managed_by: "pipe_points" },
];
const PAVE = {
structure_id: "pave",
type_id: "pavement_concrete",
placement: "interval",
chainage_m: 30,
start_m: 30,
end_m: 250,
};
const PAVE2 = {
structure_id: "pave2",
type_id: "pavement_concrete",
placement: "interval",
chainage_m: 100,
start_m: 100,
end_m: 200,
};
const PIPES = [{ chainage_m: 80.17, facility: "pipe", source: "spacing", options: {} }];
// 카드 = 20 m 측점 + 관 측점(비정규)
const CARDS = [...Array.from({ length: 16 }, (_, i) => i * 20), 80.17]
.sort((a, b) => a - b)
.map((at) => ({ station_id: `s${at}`, chainage_m: at }));
const cardAt = (at) => CARDS.find((card) => Math.abs(card.chainage_m - at) < 0.01);
/* ── 폼 흉내(B05 패널 · loadForm / loadPipeForm / resetForm 의 알림 차례) ── */
let binding = null;
let structures = [];
const form = { id: null, pipe: null };
const loadForm = (target) => {
form.id = target?.structure_id ?? null;
form.pipe = null;
binding.onSelect(target);
};
const loadPipeForm = (chainageM) => {
const hadStructure = form.id !== null;
form.id = null;
form.pipe = chainageM;
if (hadStructure) binding.onSelect(null);
};
const resetForm = () => {
const hadPipe = form.pipe !== null;
form.pipe = null;
loadForm(null);
if (hadPipe) binding.onPipeSelect(null);
};
const node = { addEventListener() {}, querySelector: () => ({ append() {} }) };
const section = {
root: node,
listRoot: node,
facility: { root: node, setRevetSideLabels() {}, recount() {} },
setTypes() {},
setStructures(next) {
structures = next;
},
setPipeFacilities() {},
hasSelection: () => form.id !== null || form.pipe !== null,
selectById(id) {
if (id === null) {
if (form.id !== null || form.pipe !== null) resetForm();
return;
}
loadForm(structures.find((entry) => entry.structure_id === id) ?? null);
},
selectPipeByChainage(chainageM) {
if (chainageM === null) {
if (form.pipe !== null) resetForm();
return;
}
loadPipeForm(chainageM);
},
};
const panelModule = loadTs(
path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Structures_Panel.ts"),
{
"../A00_Common/design/design_shared": {
bindDesignView: (_shared, _view, parts) => {
binding = parts.structures;
},
sharedStructures: () => section,
},
"../B05_Profile/B05_Profile_Api_Structures": {
fetchStructureTypes: async () => TYPES,
fetchStructures: async () => ({ structures: [] }),
readPendingStructures: () => current.structures,
pipesToStructureMarks: (pipes) =>
pipes.map((pipe) => ({
structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`,
type_id: pipe.facility,
chainage_m: pipe.chainage_m,
start_m: null,
end_m: null,
})),
structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0,
writePendingStructures() {},
dropStaleStructureTypes: async () => {}, // 정의 판 확인(322-0)
staleDefinitionNotice: () => null, // 판 알림(322-2)
},
"../B05_Profile/B05_Profile_Api_Pipes_Draft": {
readPendingPipes: () => null,
writePendingPipes: (_pid, next) => (written = next),
},
"../B05_Profile/logic/B05_Profile_UI_Drainage_Facility_Merge": {
mergeFacilityOptions: (was, next) => ({
facility: next.facility,
options: { ...(was.options ?? {}), ...(next.options ?? {}) },
}),
},
"../B05_Profile/B05_Profile_UI_Drainage_Ford_Fill": {
fordWaitsOf: () => new Set(),
waitsFordHeight: () => false,
},
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {
fetchDetailPipePoints: async () => ({ pipe_points: PIPES, basins: [] }),
},
// 페이지 자료 줄(386-2) — 저장분 한 번
"../A00_Common/design/design_page_data": {
pageTypesChecked: async () => false,
pageStructures: async () => ({ structures: [] }),
pagePipes: async () => ({ pipe_points: PIPES, basins: [] }),
},
"../common_util/common_util_summary_item": {
linkFields: () => ({}),
readGroupItems: (x) => x ?? {},
readSummaryItem: (x) => x ?? null,
},
"../B05_Profile/B05_Profile_UI_Structure_Pick_Session": { readStructurePick: () => null },
"../A00_Common/design/design_selection": selection,
"@ui/ui_template_elements": { showToast() {} },
// 관 기준 측점 정수 맞춤(234-1) — 일반 측점 없으면 정수 m 만
"../common_util/common_util_structure_station": {
snapStructureStation: (value) => Math.floor(value + 0.5),
},
"./B06_Section_UI_Adjust_Dock": {
adjustDockRoot: () => ({}),
refreshAdjustSlots() {},
resetAdjustDock() {},
setAdjustSideSlots() {},
},
"./B06_Section_UI_Page_Structures_Spec": { withSpecDefaults: (o) => o ?? {} },
// 옛 소수 관 맞춤(322-3 고침) · 관 세션 id(323-5) — 프로젝트 없음 = 옛 자리 이름
"../B05_Profile/B05_Profile_UI_Structures_Snap": {
snapNotice() {},
snapPlan: () => ({ structures: [], pipes: [], skipped: 0 }),
},
"../A00_Common/design/design_pipe_ids": {
pipeIdAt: (at) => `pipe-${at.toFixed(2)}`,
followMovedPipe() {},
},
// 요구 178 — 알약 측점 짝(진짜 `plantsStation` · 272 측점 심기 한 벌)
"../B05_Profile/B05_Profile_UI_Profile_Structures": loadTs(
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Structures.ts"),
{
"../A00_Common/touch_bridge": { pickPx: (px) => px }, // 기기대응 4-4 — 마우스 반경 그대로
"@ui/ui_template_context_menu": { createMapContextMenu() {} },
"./B05_Profile_UI_IrregularStations": {
irregularStationId: (id) => `irregular:${id}`,
isPipeStation: () => false,
},
"./B05_Profile_Api_Structures": {
structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0,
},
"../A00_Common/design/design_selection": selection,
"../common_util/common_util_structure_station": loadTs(
path.join(ROOT, "common_util", "common_util_structure_station.ts"),
),
},
),
},
);
const current = { structures: [] };
let panel = null;
let written = null;
const applied = [];
(async () => {
const out = {};
const run = async (from, to, options) => {
store.clear();
PIPES.splice(0, PIPES.length, {
chainage_m: 80.17,
facility: "pipe",
source: "spacing",
options: { pipe_diameter_mm: 800 },
});
written = null;
applied.length = 0;
panel = panelModule.createB06StructuresPanel({
projectId: PID,
shared: { view: () => "cross", lane: { set() {}, setTypes() {}, setStored() {} } },
detail: () => ({ cross_sections: CARDS }),
stationInterval: () => 20,
focusChainage() {},
markStructure() {},
onMarks() {},
applyPipeOptions: (at, patch) => applied.push([at, patch.pipe_diameter_mm ?? null]),
});
await panel.load();
binding.onPipeUpdate(from, to, { facility: "pipe", options });
return {
pipes: (written ?? []).map((pipe) => [
pipe.chainage_m,
pipe.options?.pipe_diameter_mm ?? null,
]),
applied: [...applied],
};
};
out.far = await run(80.17, 100.4, { pipe_diameter_mm: 1200 }); // 옮김 + 옵션
out.near = await run(80.17, 80, { pipe_diameter_mm: 1000 }); // 0.51 m 안 옮김 + 옵션
out.stay = await run(80.17, 80.17, { pipe_diameter_mm: 900 }); // 옵션만
process.stdout.write(JSON.stringify(out));
})().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -107,3 +107,11 @@ def test_화면_선만_바뀌면_낡음_다시_셈() -> None:
store = (ROOT / "B06_Section/B06_Section_Section_Store.ts").read_text(encoding="utf-8")
fn = store[store.index("export function recrossIfStale(") :]
assert "recrossSaved(projectId, key, detail, routeId)" in fn[: fn.index("\n}\n")]
def test_확정_성공도_상세_비움() -> None:
# 470 N1 — [확정] 은 다음 페이지(B07)로 바로 감 · [저장]처럼 상세 메모리 · 옛 세션 사본을 비움
shared = (ROOT / "A00_Common/design/design_shared.ts").read_text(encoding="utf-8")
done = shared[shared.index(" done: async (isConfirm) => {") :]
confirm = done[done.index("if (isConfirm) {") : done.index("goToWorkflowStage(")]
assert "invalidateSectionDetail(shared.projectId);" in confirm
@@ -0,0 +1,48 @@
"""PLAN 466 덧(470 조사 4.2 N2) — B06 폼 관 고침: 0.51 m 넘게 옮기며 옵션도 고치면 옵션이 옮긴 관에 붙음.
못박는 것
- 옮김 + 옵션 = 관 초안(가게 pipes)의 옮긴 관에 새 옵션 · 캐시 얹기(`applyPipeOptions`)도 옮긴 자리.
- 0.51 m 안 옮김 · 옵션만 = 그 관에 새 옵션 · 얹기 = 지금 자리.
짝: `helper_466_pipe_move_options.cjs`(진짜 `createB06StructuresPanel` · 가짜 모듈 틀 = 165 헬퍼).
./venv/Scripts/python.exe -m pytest resources/tester/test_466_pipe_move_options.py -q
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_466_pipe_move_options.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_멀리_옮기며_옵션_고침(got) -> None:
assert got["far"]["pipes"] == [[100, 1200]] # 옛: [[100, 800]] — 옵션이 옛 자리로 찾아 안 붙음
assert got["far"]["applied"] == [[100, 1200]] # 옛: 80.17(비운 옛 자리 그림)
def test_가까이_옮김_옵션만(got) -> None:
assert got["near"]["pipes"] == [[80, 1000]] and got["near"]["applied"] == [[80, 1000]]
assert got["stay"]["pipes"] == [[80.17, 900]] and got["stay"]["applied"] == [[80.17, 900]]