From a3e5988f8b787f83fa8e8869d47d17413092835d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 14:01:39 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(B06):=20=EB=8F=84=EC=88=98=EB=A1=9C/?= =?UTF-8?q?=EB=8F=8C=EB=B6=99=EC=9E=84=20=EC=B9=B8=EC=97=90=20=EB=B6=99?= =?UTF-8?q?=EC=9E=84=20=ED=98=95=EC=8B=9D(=EC=B0=B0=EB=B6=99=EC=9E=84=20?= =?UTF-8?q?=C2=B7=20=EB=A9=94=EB=B6=99=EC=9E=84)=20=C2=B7=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=81=20=E3=8E=A1=20=E2=80=94=20=EC=A0=95=EC=9D=98=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=A4=84=20=C2=B7=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=20=EC=8B=9C=ED=97=98(98-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- A00_Common/design/design_structure_guard.ts | 13 ++++++++++++- resources/master_template/구조물정의.json | 18 +++++++++++++++--- resources/tester/test_65_ford_defaults.py | 9 +++++++-- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/A00_Common/design/design_structure_guard.ts b/A00_Common/design/design_structure_guard.ts index 721bbce37..8cf7c9601 100644 --- a/A00_Common/design/design_structure_guard.ts +++ b/A00_Common/design/design_structure_guard.ts @@ -110,7 +110,7 @@ export const SOIL_ADD: SpecContainer = { items: guardItems(), }; -/** 도수로/돌붙임 칸 — 구조 · 두께(cm · 새 상자 30 · 90-2 사용자 확정) · 물넘이 포장(`ford_chute`)도 같은 칸. */ +/** 도수로/돌붙임 칸 — 구조 · 두께(cm · 새 상자 30 · 90-2 사용자 확정) · 붙임 형식 · 면적(98-3) · 물넘이 포장(`ford_chute`)도 같은 칸. */ export const CHUTE_ITEMS: SpecItem[] = [ { id: "structure", @@ -122,6 +122,17 @@ export const CHUTE_ITEMS: SpecItem[] = [ ], }, { id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "30" }, + // 붙임 형식 · 면적(98-3) — 빈칸이면 B08 구조물 집계표에 안 셈(설계자가 넣음) + { + id: "bond", + label: "붙임 형식", + kind: "select", + choices: [ + { value: "찰붙임", label: "찰붙임" }, + { value: "메붙임", label: "메붙임" }, + ], + }, + { id: "area", label: "면적 (㎡)", kind: "number", step: 0.1 }, ]; /** 도수로/돌붙임(선택 · 하나만 · 54-48) — 옛 둘 이상은 보이되 [추가]만 숨김. */ diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 5349e307d..d7293bb76 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -3361,7 +3361,11 @@ "chute_add.structure": "구조", "chute_add.structure=도수로": "도수로", "chute_add.structure=돌붙임": "돌붙임", - "chute_add.thickness": "두께 (cm)" + "chute_add.thickness": "두께 (cm)", + "chute_add.bond": "붙임 형식", + "chute_add.bond=찰붙임": "찰붙임", + "chute_add.bond=메붙임": "메붙임", + "chute_add.area": "면적 (㎡)" } }, "box_culvert": { @@ -3424,7 +3428,11 @@ "chute_add.structure": "구조", "chute_add.structure=도수로": "도수로", "chute_add.structure=돌붙임": "돌붙임", - "chute_add.thickness": "두께 (cm)" + "chute_add.thickness": "두께 (cm)", + "chute_add.bond": "붙임 형식", + "chute_add.bond=찰붙임": "찰붙임", + "chute_add.bond=메붙임": "메붙임", + "chute_add.area": "면적 (㎡)" } }, "ford_pavement": { @@ -3495,7 +3503,11 @@ "chute_add.structure": "구조", "chute_add.structure=도수로": "도수로", "chute_add.structure=돌붙임": "돌붙임", - "chute_add.thickness": "두께 (cm)" + "chute_add.thickness": "두께 (cm)", + "chute_add.bond": "붙임 형식", + "chute_add.bond=찰붙임": "찰붙임", + "chute_add.bond=메붙임": "메붙임", + "chute_add.area": "면적 (㎡)" } }, "revetment": { diff --git a/resources/tester/test_65_ford_defaults.py b/resources/tester/test_65_ford_defaults.py index 5bc682fc4..cd8102698 100644 --- a/resources/tester/test_65_ford_defaults.py +++ b/resources/tester/test_65_ford_defaults.py @@ -64,8 +64,13 @@ def test_유입_유출은_구조와_흙막이_칸만(out): "before", "after", ] - # 도수로/돌붙임 = 구조 · 두께(cm · 새 상자 30 · 90-2) - assert out[spec]["chute_add"] == [["structure", None, None], ["thickness", None, "30"]] + # 도수로/돌붙임 = 구조 · 두께(cm · 새 상자 30 · 90-2) · 붙임 형식 · 면적(98-3 · 기본 없음) + assert out[spec]["chute_add"] == [ + ["structure", None, None], + ["thickness", None, "30"], + ["bond", None, None], + ["area", None, None], + ] keys = out[f"{spec}Keys"] assert keys["prefixes"] == ["soil_add_", "chute_add_"] # 측벽 높이 · 좌우 · 상하 · 바닥판 · 날개벽 치수 칸 없음 From 456c11aa5d76f923fd53340400a48e539e730729 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 14:02:30 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(B07-CAD):=20Space=20=3D=20Enter(?= =?UTF-8?q?=EC=98=A4=ED=86=A0=EC=BA=90=EB=93=9C)=20=C2=B7=20=EB=B9=88=20En?= =?UTF-8?q?ter=20=C2=B7=20Space=20=3D=20=EB=A7=88=EC=A7=80=EB=A7=89=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=20=EB=B0=98=EB=B3=B5(97-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 캔버스 · 명령줄에서 Space(수정키 없음 · 길게 눌러 반복 아님) = Enter · 글 단계(TEXT · MTEXT · 표 칸 · 이름 등)는 띄어쓰기 - 글 단계 판정 = sequence-tool 단계 meta kind 'text'(activeToolWantsFreeText) - 빈 입력 + 선택 도구 대기에서 키보드 Enter · Space = repeatLastCommand · 빈 우클릭은 확정만 - 한글 조합 중 Space · Shift/Ctrl+Space 무시 - 시험 ime-sink: 「L␣」 = LINE · TEXT 띄어쓰기 · 조합 중 · 수정키 · 반복 무시 · 빈 Space · Enter 반복 · 우클릭 반복 안 함 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- .../openwebcad/src/components/CommandLine.tsx | 11 ++- .../src/inputController/ime-sink.test.ts | 92 ++++++++++++++++++- .../src/inputController/ime-sink.ts | 14 +++ .../src/inputController/input-controller.ts | 24 ++++- .../src/tools/factories/sequence-tool.ts | 2 +- 5 files changed, 136 insertions(+), 7 deletions(-) diff --git a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx index 86c97e203..9d354a25a 100644 --- a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx +++ b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx @@ -2,6 +2,7 @@ import { type FC, type FormEvent, useMemo, useRef, useState } from 'react'; import { describeCommand, matchCommandPrefixes } from '../commands/registry'; import { getCommandHistory, runCommandInput } from '../commands/run-command'; +import { activeToolWantsFreeText, isPlainSpace } from '../inputController/ime-sink'; import { getInputController, getLastStateInstructions } from '../state'; export const CommandLine: FC = () => { @@ -18,7 +19,7 @@ export const CommandLine: FC = () => { const value = text.trim(); if (!value) { // 빈 ENTER는 활성 명령의 확정 신호다 (선택 끝내기·직전 명령 반복) - getInputController().handleEnterKey(); + getInputController().handleEnterKey(true); return; } // 진행 중인 명령이 문자·숫자를 기다리면 그쪽으로, 아니면 명령으로 해석된다 @@ -54,6 +55,14 @@ export const CommandLine: FC = () => { onChange={(event) => setText(event.target.value)} onKeyDown={(event) => { if (event.nativeEvent.isComposing) return; // 한글 조합 중 키는 IME 몫 + if (event.key === ' ' && !activeToolWantsFreeText()) { + // Space = Enter(오토캐드) · 글 단계는 띄어쓰기 · 수정키 Space 는 그대로(입력칸 몫) + if (isPlainSpace(event)) { + event.preventDefault(); + event.currentTarget.form?.requestSubmit(); + } else if (event.repeat) event.preventDefault(); + return; + } if (event.key === 'Escape') { event.preventDefault(); getInputController().handleEscapeKey(); diff --git a/B07_DesignDetail/openwebcad/src/inputController/ime-sink.test.ts b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.test.ts index 1be500de3..22ed47630 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/ime-sink.test.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import { ImeSink, isImeKey } from './ime-sink.ts'; +import { Actor } from 'xstate'; +import { MouseButton } from '../App.types.ts'; +import { getActiveToolActor, setActiveToolActor } from '../state.ts'; +import { Tool } from '../tools.ts'; +import { createSequenceTool } from '../tools/factories/sequence-tool.ts'; +import { selectToolStateMachine } from '../tools/select-tool.ts'; +import { activeToolWantsFreeText, ImeSink, isImeKey } from './ime-sink.ts'; import { InputController } from './input-controller.ts'; describe('한글 IME 입력', () => { @@ -48,3 +54,87 @@ describe('한글 IME 입력', () => { expect((controller as unknown as { text: string }).text).toBe('가나다'); }); }); + +describe('Space = Enter(오토캐드)', () => { + const press = (controller: InputController, key: string, extra: Partial = {}) => + controller.handleKeyStroke({ + key, + keyCode: 0, + isComposing: false, + shiftKey: false, + ctrlKey: false, + altKey: false, + metaKey: false, + repeat: false, + target: null, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...extra, + } as unknown as KeyboardEvent); + const textOf = (controller: InputController) => (controller as unknown as { text: string }).text; + const activeTool = () => getActiveToolActor()?.getSnapshot()?.context?.type; + const selectIdle = () => setActiveToolActor(new Actor(selectToolStateMachine), false); + + it('「L␣」 = LINE 실행', () => { + selectIdle(); + const controller = new InputController(); + press(controller, 'l'); + press(controller, ' '); + expect(activeTool()).toBe(Tool.LINE); + expect(textOf(controller)).toBe(''); + }); + + it('조합 중 Space · Shift/Ctrl+Space · 길게 눌러 반복은 무시', () => { + selectIdle(); + const controller = new InputController(); + press(controller, 'l'); + press(controller, ' ', { isComposing: true, keyCode: 229 }); + press(controller, ' ', { shiftKey: true }); + press(controller, ' ', { ctrlKey: true }); + press(controller, ' ', { repeat: true }); + expect(textOf(controller)).toBe('L'); + expect(activeTool()).toBe(Tool.SELECT); + }); + + it('글 단계(TEXT 등)에서는 띄어쓰기', () => { + const machine = createSequenceTool({ + tool: Tool.TEXT, + steps: [{ kind: 'text', instructions: '문자를 입력하십시오.' }], + commit: () => {}, + }); + setActiveToolActor(new Actor(machine), false); + expect(activeToolWantsFreeText()).toBe(true); + const controller = new InputController(); + press(controller, 'a'); + press(controller, ' '); + press(controller, 'b'); + expect(textOf(controller)).toBe('A B'); + expect(activeTool()).toBe(Tool.TEXT); + }); + + it('빈 Space · Enter = 마지막 명령 반복 · 빈 우클릭은 반복 안 함', () => { + selectIdle(); + const controller = new InputController(); + press(controller, 'l'); + press(controller, 'Enter'); + expect(activeTool()).toBe(Tool.LINE); + + selectIdle(); + expect(activeToolWantsFreeText()).toBe(false); + press(controller, ' '); + expect(activeTool()).toBe(Tool.LINE); + + selectIdle(); + press(controller, 'Enter'); + expect(activeTool()).toBe(Tool.LINE); + + selectIdle(); + controller.handleMouseUp({ + button: MouseButton.Right, + target: null, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as MouseEvent); + expect(activeTool()).toBe(Tool.SELECT); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts index e79a4e02b..327220356 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts @@ -15,6 +15,20 @@ export function activeToolAcceptsText(): boolean { return !!snapshot?.machine?.states?.[snapshot?.value]?.config?.on?.TEXT_INPUT; } +/** 지금 단계가 자유 글(TEXT · MTEXT · 표 칸 · 이름 …)을 받는가 — Space 가 띄어쓰기(아니면 Enter) */ +export function activeToolWantsFreeText(): boolean { + const meta = getActiveToolActor()?.getSnapshot()?.getMeta?.() as + | Record + | undefined; + return Object.values(meta ?? {}).some((stateMeta) => stateMeta?.kind === 'text'); +} + +/** Enter 로 받을 Space — 수정키(Shift · Ctrl · Alt · Meta: 한/영 전환 등) · 길게 눌러 반복은 아님 */ +export const isPlainSpace = ( + evt: Pick +): boolean => + evt.key === ' ' && !evt.shiftKey && !evt.ctrlKey && !evt.altKey && !evt.metaKey && !evt.repeat; + export class ImeSink { /** 조합 중인 글자 — 커서 옆 입력 칸에 미리 보인다 */ public composing = ''; diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index 8e8a27c61..e74c7502c 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -14,7 +14,7 @@ import { matchCommandPrefixes, resolveCommandInput, } from '../commands/registry.ts'; -import { runCommand } from '../commands/run-command.ts'; +import { repeatLastCommand, runCommand } from '../commands/run-command.ts'; import { requestHostSave } from '../integration/aislo-drawing-bridge.ts'; import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts'; import { calculateAngleGuidesAndSnapPoints } from '../helpers/calculate-angle-guides-and-snap-points.ts'; @@ -58,7 +58,13 @@ import { type RelativePointInputEvent, type TextInputEvent, } from '../tools/tool.types.ts'; -import { activeToolAcceptsText, ImeSink, isImeKey } from './ime-sink.ts'; +import { + activeToolAcceptsText, + activeToolWantsFreeText, + ImeSink, + isImeKey, + isPlainSpace, +} from './ime-sink.ts'; import { drawInputField } from './input-field-draw.ts'; import { ABSOLUTE_POINT_REGEXP, @@ -437,8 +443,14 @@ export class InputController { this.handleEscapeKey(); } else if (evt.key === 'Enter') { // User wants to submit the input or submit the action - this.handleEnterKey(); + this.handleEnterKey(true); this.ime.sync(activeToolAcceptsText()); + } else if (evt.key === ' ' && !activeToolWantsFreeText()) { + // Space = Enter(오토캐드) · 글 단계는 아래 띄어쓰기 · 수정키 · 길게 눌러 반복은 무시 + if (isPlainSpace(evt)) { + this.handleEnterKey(true); + this.ime.sync(activeToolAcceptsText()); + } } else if (evt.key === 'ArrowDown') { // Move the screen down getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('down', evt.shiftKey)); @@ -482,7 +494,8 @@ export class InputController { return matchCommandPrefixes(this.text).slice(0, 6).map(describeCommand); } - public handleEnterKey() { + /** @param repeatIfIdle 키보드 Enter · Space — 빈 입력 + 선택 도구 대기면 마지막 명령 반복(우클릭은 확정만) */ + public handleEnterKey(repeatIfIdle = false) { // submit the text as input to the active tool and clear the input field const activeToolCanHandleTextInput = activeToolAcceptsText(); @@ -492,6 +505,9 @@ export class InputController { activeTool: getActiveToolActor(), }); const wasSelectTool = this.isSelectToolActive(); + const selectIdle = + getActiveToolActor()?.getSnapshot()?.value === SelectState.WAITING_FOR_FIRST_SELECT_POINT; + if (repeatIfIdle && wasSelectTool && selectIdle && repeatLastCommand()) return; // Send the ENTER event to the active tool getActiveToolActor()?.send({ type: ActorEvent.ENTER, diff --git a/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts b/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts index 3e5f63854..435f50c37 100644 --- a/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts @@ -331,7 +331,7 @@ export function createSequenceTool(config: SequenceToolConfig) { } states[STEP_STATE(index)] = { - meta: { instructions: step.instructions }, + meta: { instructions: step.instructions, kind: step.kind }, entry: () => { // 객체 선택 단계가 아니면 스냅·각도 가이드를 켠다 setShouldDrawHelpers(useHelpers && step.kind === 'point'); From 02225ed071a731ad68a53e104b0c7ced86224d14 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 14:08:10 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(B08):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=ED=91=9C=20=EC=84=9C=EB=B2=84=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=20=EA=B8=B8=20=E2=80=94=20=EB=B0=B0=EA=B4=80=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=EA=B4=80=EB=A7=A4=EC=84=A4=20=C2=B7=20=EC=BB=A4?= =?UTF-8?q?=ED=94=8C=EB=A7=81=20=C2=B7=20=EA=B4=80=EB=B3=B4=ED=98=B8?= =?UTF-8?q?=EA=B3=B5=20=C2=B7=20=ED=9D=99=EB=A7=89=EC=9D=B4=20=ED=98=95?= =?UTF-8?q?=ED=83=9C=20=C2=B7=20H=20=C2=B7=20=EB=8F=8C=EB=B6=99=EC=9E=84?= =?UTF-8?q?=20=C2=B7=20=EA=B3=84=20=C2=B7=20=EC=97=B4=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20id(98-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- B08_Quantity/B08_Quantity_Router_Summary.py | 309 +++++++++++++++++++ main.py | 2 + resources/tester/test_98_b08_pipe_summary.py | 215 +++++++++++++ 3 files changed, 526 insertions(+) create mode 100644 B08_Quantity/B08_Quantity_Router_Summary.py create mode 100644 resources/tester/test_98_b08_pipe_summary.py diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py new file mode 100644 index 000000000..c70159575 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -0,0 +1,309 @@ +"""B08 구조물 집계표 길(PLAN 98-1) — 읽기만 · 계산 서버 단독(CLAUDE.md 5장 ③). + + GET /api/projects/{id}/quantity/summary + 배관(횡단배수관) 한 곳마다 컨테이너 값 → 실무 구조물집계표 꼴 열 · 측점 줄 · 계 + +열 = 설계에 쓰인 값만 펼침 · 머리 = `group`[구조물, 형식, 규격] + `label`(단위 줄): + 흙막이 › 형태 › H=높이 m 유입(흙막이일 때) · 유출 · 흙막이 n(실제 선 단까지) + · 길이 = 전 + 후 · 빈 높이 = 횡단도가 그리는 관경 최소 높이 + 돌붙임 › 찰붙임 · 메붙임 ㎡ 도수로/돌붙임 붙임 형식 · 면적(빈칸이면 안 셈 · 98-3) + 관 공 › Φ{관경}m/m › 관매설 m B06 관 길이(1 m 올림 그대로) + › 커플링 ea 올림(L ÷ 1본) − 1 · 1본 = 프로젝트 층 M02 구조물집계표 변수 + › 관보호공 모양 실제로 선 집수정 모양마다 1 개소(98-2 `inlet_basin`) +머리 글 = M02 정의 이름(`구조물정의.json` 종류.pipe.이름 — 사용자가 M02 에서 고침) · +열마다 `combos` = 그 값을 낸 컨테이너에 이은 조합 id(`{층}|{주인}|{키}` · 95-3 모달 · 맨 윗 머리). +설계 = B08 토공과 같은 길(확정 노선 먼저) · 관 · 컨테이너 줄 = M02 채움 `collect_items` 재사용. +""" + +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 B06_Section.B06_Section_Engine_Culvert import pipe_wall_min_height_m +from B06_Section.B06_Section_Repository import ( + get_confirmed_route_context, + get_cross_section_designs, + get_workflow_route_context, +) +from B08_Quantity.B08_Quantity_Router_Hydro import _project_root +from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file +from common_util.common_util_summary_item import GROUP_JOIN +from config.config_db import get_db_pool +from M02_MasterTemplete import M02_Table_Fill as fill +from M02_MasterTemplete import M02_Template_Layers as layers +from M02_MasterTemplete.M02_Table_Fill_Spec import soil_tiers_from_designs +from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition + +router = APIRouter(prefix="/api/projects", tags=["B08 Summary"]) + +TABLE_NAME = "구조물집계표" +ONE_LENGTH_VAR = "파형강관_1본_m" +#: 필수 컨테이너 잇기 키(`design_structure_pipe.ts` link) — 관 · 유입 · 유출 +LINKS = {"pipe": "grv6lny", "inlet": "gnbf9m9", "outlet": "g9lq9uk"} +SOIL = "기슭막이" # 저장 값(화면 이름 흙막이) +FORMS = ("돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자") +SHAPES = ("I", "L", "U") +BONDS = ("찰붙임", "메붙임") + + +def _num(value: Any) -> float | None: + if isinstance(value, bool) or value in (None, ""): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _combo(item: Any) -> str | None: + """집계 항목 → 조합 id(화면 `comboId` 와 같은 꼴) · 없으면 None.""" + if not isinstance(item, dict) or not item.get("key"): + return None + return f"{item.get('layer')}|{item.get('owner') or ''}|{item['key']}" + + +def one_pipe_length(project_root: str | Path) -> float | None: + """1본 길이(m) — 프로젝트 층 구조물집계표 변수 · 없으면 시스템 층 · 빈칸이면 None.""" + for folder in (layers.project_dir(project_root), layers.system_dir()): + found = layers.read_template(folder, "table", TABLE_NAME) + if found is not None: + value = _num((found["문서"].get("변수") or {}).get(ONE_LENGTH_VAR)) + return value if value and value > 0 else None + return None + + +def couplings(length_m: float | None, one_m: float | None) -> int | None: + """커플링 = 올림(L ÷ 1본) − 1(0 아래 없음) · 길이나 1본이 빈칸이면 None.""" + if not length_m or not one_m: + return None + return max(math.ceil(length_m / one_m - 1e-9) - 1, 0) + + +def _basins(designs: Any) -> dict[float, str]: + """설계 → `{측점: 실제로 선 집수정 모양}` — 안 섰으면 "" · 기록 없는 측점은 안 담음(98-2).""" + found: dict[float, str] = {} + for row in designs or []: + design = row.get("design") if isinstance(row, dict) else None + if isinstance(design, dict) and "inlet_basin" in design: + found[round(float(row.get("chainage_m") or 0.0), 3)] = str(design["inlet_basin"] or "") + return found + + +def _revet_length(options: dict[str, Any], head: str, unit: str) -> float | None: + """흙막이 길이 = 전 + 후 · 둘 다 비면 길이 칸.""" + before = _num(options.get(f"{head}before{unit}")) + after = _num(options.get(f"{head}after{unit}")) + if before is None and after is None: + return _num(options.get(f"{head}length{unit}")) + return (before or 0.0) + (after or 0.0) + + +class _Sheet: + """열 · 측점 줄 · 계를 모음 — 같은 측점(1 cm)의 같은 열은 더함.""" + + def __init__(self) -> None: + self.columns: dict[str, dict[str, Any]] = {} + self.rows: dict[int, dict[str, Any]] = {} + + def add( + self, + chainage: float, + key: str, + sort: tuple[Any, ...], + group: list[str], + label: str, + value: float | None, + combos: list[str | None], + ) -> None: + cid = f"pipe:{key}" + column = self.columns.setdefault( + cid, {"id": cid, "group": group, "label": label, "combos": [], "_sort": sort} + ) + for combo in combos: + if combo and combo not in column["combos"]: + column["combos"].append(combo) + row = self.rows.setdefault(round(chainage * 100), {"chainage_m": chainage, "values": {}}) + if value is not None: # 열은 세움 — 놓였는데 값이 빈칸인 것이 보여야 함 + row["values"][cid] = round((row["values"].get(cid) or 0.0) + value, 3) + + def result(self) -> dict[str, Any]: + columns = sorted(self.columns.values(), key=lambda column: column["_sort"]) + rows = [self.rows[key] for key in sorted(self.rows)] + total: dict[str, float] = {} + for row in rows: + for cid, value in row["values"].items(): + total[cid] = round(total.get(cid, 0.0) + value, 3) + return { + "columns": [{k: v for k, v in c.items() if k != "_sort"} for c in columns], + "rows": rows, + "total": total, + } + + +def _order(values: tuple[str, ...], value: str) -> int: + return values.index(value) if value in values else len(values) + + +def pipe_summary( + project_root: str | Path, + designs: list[dict[str, Any]], + names: dict[str, str], + one_m: float | None, +) -> dict[str, Any]: + """배관 한 곳마다 컨테이너 값 → 집계표 열 · 줄 · 계(저장 안 함).""" + items = fill.collect_items( + project_root, + fill.pipe_lengths_from_designs(designs), + soil_tiers=soil_tiers_from_designs(designs), + ) + # 높이 등 빈 칸은 정의 기본값이 아니라 그림 규칙을 따라야 해 저장 그대로를 따로 읽음 + raw = { + f"pipe@{round(float(point.chainage_m), 3)}": dict(point.options or {}) + for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))) + if (point.facility or "pipe") == "pipe" + } + basins = _basins(designs) + by_owner: dict[str, dict[str, Any]] = {} + for item in items: + if item["type_id"] != "pipe": + continue + owner, _, link = item["복사본"].partition(GROUP_JOIN) + entry = by_owner.setdefault(owner, {"links": {}}) + if link: + entry["links"][link] = _combo(item.get("summary_item")) + else: + entry["base"] = item + + sheet = _Sheet() + no_length = no_basin = 0 + soil_name = names.get(f"outlet.structure={SOIL}", SOIL) + for owner, entry in by_owner.items(): + base = entry.get("base") + if base is None: + continue + links = entry["links"] + options = raw.get(owner, {}) + at = float(base["chainage_m"]) + diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000) + pipe_head = ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"] + pipe_combos = [_combo(base.get("summary_item")), links.get(LINKS["pipe"])] + length = base.get("관연장") + no_length += not length + sheet.add( + at, + f"len|{diameter}", + (2, diameter, 0), + [*pipe_head, "관매설"], + "m", + length, + pipe_combos, + ) + sheet.add( + at, + f"cp|{diameter}", + (2, diameter, 1), + [*pipe_head, "커플링"], + "ea", + couplings(length, one_m), + pipe_combos, + ) + shape = fill._nearest(basins, at) + no_basin += shape is None + if shape: + sheet.add( + at, + f"basin|{diameter}|{shape}", + (2, diameter, 2 + _order(SHAPES, shape), shape), + [*pipe_head, "관보호공"], + names.get(f"inlet.form={shape}", shape), + 1.0, + [links.get(LINKS["inlet"])], + ) + + # 흙막이 — 유입(흙막이일 때) · 유출 · 흙막이 n(container_rows 가 실제 선 단까지만 냄) + # 저장 키 = 필수는 `{쪽}_revet_{칸}_m` · 선택 흙막이는 `soil_add_{n}_{칸}`(명세 인스턴스 키) + sources = [ + (f"{side}_revet_", "_m", side, links.get(LINKS[side])) + for side in ("inlet", "outlet") + if options.get(f"{side}_type") == SOIL + ] + sources += [ + (f"soil_add_{link[len('gsoiladd') :]}_", "", "soil_add", combo) + for link, combo in links.items() + if link.startswith("gsoiladd") + ] + for head, unit, container, combo in sources: + form = str(options.get(f"{head}form") or "") + height = _num(options.get(f"{head}height{unit}")) + if height is None: + height = pipe_wall_min_height_m(diameter / 1000) + height_text = fill._label("height_m", height) + sheet.add( + at, + f"revet|{form or fill.EMPTY_LABEL}|{height_text}", + (0, _order(FORMS, form), form, height), + [ + soil_name, + names.get(f"{container}.form={form}", form) or fill.EMPTY_TEXT, + f"H={height_text}", + ], + "m", + _revet_length(options, head, unit), + [combo], + ) + + # 돌붙임 — 붙임 형식 · 면적이 다 있을 때만(설계자가 넣음) + for link, combo in links.items(): + if not link.startswith("gchuteadd"): + continue + head = f"chute_add_{link[len('gchuteadd') :]}_" + bond = str(options.get(f"{head}bond") or "") + area = _num(options.get(f"{head}area")) + if options.get(f"{head}structure") != "돌붙임" or not bond or not area: + continue + sheet.add( + at, + f"chute|{bond}", + (1, _order(BONDS, bond), bond), + [ + names.get("chute_add.structure=돌붙임", "돌붙임"), + names.get(f"chute_add.bond={bond}", bond), + ], + "㎡", + area, + [combo], + ) + + out = sheet.result() + notes = [] + if no_length: + notes.append(f"관 연장 없음 {no_length}곳 — B06 횡단 [저장] 뒤 채워짐") + if no_basin: + notes.append(f"집수정 모양 기록 없음 {no_basin}곳 — B06 횡단 [저장] 뒤 채워짐") + if one_m is None: + notes.append(f"1본 길이 변수({ONE_LENGTH_VAR}) 빈칸 — 커플링 빈칸") + return {**out, "notes": notes, "one_length_m": one_m} + + +def _summary(root: Path, designs: list[dict[str, Any]]) -> dict[str, Any]: + names = ((definition.read().get("종류") or {}).get("pipe") or {}).get("이름") or {} + return pipe_summary(root, designs, names, one_pipe_length(root)) + + +@router.get("/{project_id}/quantity/summary") +async def structure_summary(project_id: UUID) -> dict[str, Any]: + root = await _project_root(project_id) + async with get_db_pool().acquire() as connection: + context = await get_confirmed_route_context(connection, project_id) + context = context or await get_workflow_route_context(connection, project_id) + # 노선 전이면 설계 없이 — 관은 서 있고 연장 · 집수정 모양만 빈칸(알림) + designs = ( + await get_cross_section_designs(connection, int(context["route_id"])) if context else [] + ) + return await asyncio.to_thread(_summary, root, designs) diff --git a/main.py b/main.py index 9f9334317..1205b1eb6 100644 --- a/main.py +++ b/main.py @@ -65,6 +65,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_r from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router from B08_Quantity.B08_Quantity_Router_Hydro import router as b08_hydro_router +from B08_Quantity.B08_Quantity_Router_Summary import router as b08_summary_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router @@ -647,6 +648,7 @@ app.include_router(b07_frame_router, dependencies=protected_with_company) app.include_router(b07_plan_kinds_router, dependencies=protected_with_company) app.include_router(b08_hydro_router, dependencies=protected_with_company) app.include_router(b08_earthwork_router, dependencies=protected_with_company) +app.include_router(b08_summary_router, dependencies=protected_with_company) # B08 수량·B09 원가계산 라우터 13 개는 끊음 — 코드는 `old_code/` 에 있고 화면은 빈 틀만 뜬다(PLAN 7-3). # 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다. system_admin_only = [Depends(verify_session), Depends(require_system_admin)] diff --git a/resources/tester/test_98_b08_pipe_summary.py b/resources/tester/test_98_b08_pipe_summary.py new file mode 100644 index 000000000..9caf86083 --- /dev/null +++ b/resources/tester/test_98_b08_pipe_summary.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- +"""B08 구조물 집계표 「횡단배수 | 배관」 실무 꼴 — PLAN 98-1. + +2공구 구조물집계표(`4.2 수량산출(2공구).xlsx`) 꼴 견본: + 71+13 Φ1200 L22 · 유입 흙막이(찰 H2.0) · 유출 메 H2.0 + · 흙막이 1(메 H2.0 · 선 단 1 — 흙막이 2 는 안 섬) · 찰붙임 10 ㎡ → 커플링 올림(22/6)−1 = 3 + 78+7 Φ800 L18 · 집수정 ㄷ(U) · 유출 메 H2.0 10 m · 메붙임 20 ㎡ → 커플링 2 + 84+0 Φ800 L16 · 집수정 ㄴ(L) · 유출 메 H2.0 → 커플링 올림(16/6)−1 = 2 · 1본 8 m 면 1 + 89+10 Φ800 L(없음) · 유출 높이 빈칸 → 관경 최소 1.8 · 집수정 기록 없음 → 알림 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from B08_Quantity import B08_Quantity_Router_Summary as summary +from M02_MasterTemplete import M02_Template_Layers as layers +from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition + +COMBO = {"layer": "system", "owner": None, "key": "SS000011", "name": "횡단배수 배관"} +OUT = {"layer": "company", "owner": "3", "key": "SC3-000001", "name": "기슭막이 메쌓기"} +WALL = {"side": "outlet", "key": "extra0"} + + +def _revet(side: str, form: str, height: float | None) -> dict: + found = { + f"{side}_type": "기슭막이", + f"{side}_revet_form": form, + f"{side}_revet_before_m": 5, + f"{side}_revet_after_m": 5, + f"{side}_revet_length_m": 99, # 전 + 후가 앞섬 + } + if height is not None: + found[f"{side}_revet_height_m"] = height + return found + + +def _chute(bond: str, area: float) -> dict: + return {"chute_add_1_structure": "돌붙임", "chute_add_1_bond": bond, "chute_add_1_area": area} + + +POINTS = [ + { + "chainage_m": 1433.0, + "options": { + "pipe_diameter_mm": 1200, + **_revet("inlet", "돌쌓기(찰)", 2.0), + **_revet("outlet", "돌쌓기(메)", 2.0), + "soil_add_1_form": "돌쌓기(메)", + "soil_add_1_height": 2, + "soil_add_1_before": 5, + "soil_add_1_after": 5, + "soil_add_2_form": "돌쌓기(메)", + "soil_add_2_height": 2, + **_chute("찰붙임", 10), + }, + "summary_item": COMBO, + "group_items": {"g9lq9uk": OUT}, + }, + { + "chainage_m": 1567.0, + "options": { + "pipe_diameter_mm": 800, + "inlet_type": "집수정", + "inlet_structure": "auto", + **_revet("outlet", "돌쌓기(메)", 2.0), + **_chute("메붙임", 20), + }, + "summary_item": COMBO, + }, + { + "chainage_m": 1680.0, + "options": { + "pipe_diameter_mm": "800", + "inlet_type": "집수정", + **_revet("outlet", "돌쌓기(메)", 2.0), + "chute_add_1_structure": "돌붙임", # 붙임 형식 · 면적 빈칸 → 안 셈 + }, + }, + { + "chainage_m": 1790.0, + "options": {"pipe_diameter_mm": 800, "inlet_type": "집수정", **_revet("outlet", "", None)}, + }, +] +DESIGNS = [ + { + "chainage_m": 1433.0, + "design": {"pipe_length_m": 22, "inlet_basin": None, "extra_walls": [WALL]}, + }, + {"chainage_m": 1567.2, "design": {"pipe_length_m": 18, "inlet_basin": "U", "extra_walls": []}}, + {"chainage_m": 1680.0, "design": {"pipe_length_m": 16, "inlet_basin": "L", "extra_walls": []}}, + {"chainage_m": 1790.0, "design": {"extra_walls": []}}, +] + + +@pytest.fixture +def root(tmp_path: Path) -> Path: + found = tmp_path / "proj" + edits = found / "B04_PreProcess/drainage/edits" + edits.mkdir(parents=True) + points = [{**p, "source": "user"} for p in POINTS] + (edits / "pipe_points.json").write_text( + json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8" + ) + return found + + +def _run(root: Path, one_m: float | None = 6.0) -> dict: + names = definition.read()["종류"]["pipe"]["이름"] + return summary.pipe_summary(root, DESIGNS, names, one_m) + + +def _cell(out: dict, at: float, cid: str): + row = next(r for r in out["rows"] if abs(r["chainage_m"] - at) < 0.01) + return row["values"].get(cid) + + +def test_관경마다_관매설_커플링_관보호공(root): + out = _run(root) + assert _cell(out, 1433, "pipe:len|1200") == 22 + assert _cell(out, 1433, "pipe:cp|1200") == 3 # 올림(22/6) − 1 + assert _cell(out, 1567, "pipe:len|800") == 18 + assert _cell(out, 1567, "pipe:cp|800") == 2 + assert _cell(out, 1680, "pipe:cp|800") == 2 # 올림(16/6) − 1 + assert _cell(out, 1567, "pipe:basin|800|U") == 1 # 실제로 선 모양(자동 → 지형 규칙 결과) + assert _cell(out, 1680, "pipe:basin|800|L") == 1 + assert _cell(out, 1790, "pipe:len|800") is None # 연장 없음 = 빈칸(0 아님) + assert _run(root, 8.0)["rows"][2]["values"]["pipe:cp|800"] == 1 # 1본 8 m: 올림(16/8) − 1 + ids = [c["id"] for c in out["columns"]] + assert "pipe:basin|1200|U" not in ids and not any(i.startswith("pipe:basin|1200") for i in ids) + + +def test_흙막이_형태_높이마다_길이(root): + out = _run(root) + # 71+13: 유입 찰 10 · 유출 메 10 + 흙막이 1 메 10(선 단 1 — 흙막이 2 는 안 셈) + assert _cell(out, 1433, "pipe:revet|돌쌓기(찰)|2.0") == 10 + assert _cell(out, 1433, "pipe:revet|돌쌓기(메)|2.0") == 20 + # 집수정 유입은 흙막이로 안 셈 · 유출만 + assert _cell(out, 1567, "pipe:revet|돌쌓기(찰)|2.0") is None + assert _cell(out, 1567, "pipe:revet|돌쌓기(메)|2.0") == 10 + # 빈 높이 = 횡단도 관경 최소(Φ800 → 1.8) · 정의 기본 1.5 아님 · 빈 형태 = 미입력 + assert _cell(out, 1790, "pipe:revet|-|1.8") == 10 + head = next(c for c in out["columns"] if c["id"] == "pipe:revet|-|1.8") + assert head["group"][1:] == ["미입력", "H=1.8"] + + +def test_돌붙임은_형식_면적이_다_있을_때만(root): + out = _run(root) + assert _cell(out, 1433, "pipe:chute|찰붙임") == 10 + assert _cell(out, 1567, "pipe:chute|메붙임") == 20 + assert _cell(out, 1680, "pipe:chute|메붙임") is None + + +def test_머리_단위_차례_조합_계(root): + out = _run(root) + cols = {c["id"]: c for c in out["columns"]} + assert cols["pipe:len|800"]["group"] == ["관 공", "Φ800m/m", "관매설"] + assert cols["pipe:len|800"]["label"] == "m" + assert cols["pipe:cp|800"]["label"] == "ea" + assert cols["pipe:basin|800|U"]["group"][2] == "관보호공" + assert cols["pipe:basin|800|U"]["label"] == "ㄷ형" # M02 정의 이름(inlet.form=U) + assert cols["pipe:chute|찰붙임"]["label"] == "㎡" + assert cols["pipe:revet|돌쌓기(메)|2.0"]["group"][2] == "H=2.0" + # 차례: 흙막이 → 돌붙임 → 관 공(관경 오름 · 관매설 · 커플링 · 관보호공 I · L · U) + ids = [c["id"] for c in out["columns"]] + assert ids.index("pipe:revet|돌쌓기(찰)|2.0") < ids.index("pipe:revet|돌쌓기(메)|2.0") + assert ids.index("pipe:revet|돌쌓기(메)|2.0") < ids.index("pipe:chute|찰붙임") + assert ids.index("pipe:chute|메붙임") < ids.index("pipe:len|800") + assert ids[ids.index("pipe:len|800") :] == [ + "pipe:len|800", + "pipe:cp|800", + "pipe:basin|800|L", + "pipe:basin|800|U", + "pipe:len|1200", + "pipe:cp|1200", + ] + # 열마다 조합 id(95-3 모달 · 맨 윗 머리) — 그 값을 낸 컨테이너에 이은 조합 + assert cols["pipe:len|800"]["combos"] == ["system||SS000011"] + assert cols["pipe:revet|돌쌓기(메)|2.0"]["combos"] == ["company|3|SC3-000001"] + # 계 줄 = 측점 값 합 + assert out["total"]["pipe:len|800"] == 34 + assert out["total"]["pipe:cp|800"] == 4 + assert out["total"]["pipe:revet|돌쌓기(메)|2.0"] == 40 # 20 + 10 + 10 + assert [round(r["chainage_m"]) for r in out["rows"]] == [1433, 1567, 1680, 1790] + + +def test_알림_연장_집수정_1본(root): + notes = _run(root, None)["notes"] + assert any("관 연장 없음 1곳" in n for n in notes) + assert any("집수정 모양 기록 없음 1곳" in n for n in notes) + assert any("파형강관_1본_m" in n for n in notes) + + +def test_1본_길이는_프로젝트_층_변수(root, tmp_path, monkeypatch): + system = tmp_path / "master" + (system / "table").mkdir(parents=True) + (system / "table/구조물집계표.json").write_text( + json.dumps({"변수": {"파형강관_1본_m": 8}}), encoding="utf-8" + ) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + assert summary.one_pipe_length(root) == 8 + mine = layers.template_path(layers.project_dir(root), "table", "구조물집계표") + mine.parent.mkdir(parents=True) + mine.write_text(json.dumps({"변수": {"파형강관_1본_m": 6}}), encoding="utf-8") + assert summary.one_pipe_length(root) == 6 + mine.write_text(json.dumps({"변수": {"파형강관_1본_m": ""}}), encoding="utf-8") + assert summary.one_pipe_length(root) is None + + +def test_라우터_등록(): + text = (Path(__file__).resolve().parents[2] / "main.py").read_text(encoding="utf-8") + assert "app.include_router(b08_summary_router, dependencies=protected_with_company)" in text From 22266508d49a4096aeb5ab3c22f1897dd3c23412 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 14:10:03 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix(B01):=20=ED=95=AD=EB=AA=A9=EB=B3=84=20?= =?UTF-8?q?=ED=83=AD=20=ED=99=94=EB=A9=B4=20=EB=8B=A4=EB=93=AC=EC=9D=8C=20?= =?UTF-8?q?=E2=80=94=20=ED=9A=8C=EC=82=AC=20=EC=A0=95=EB=B3=B4=20=EC=B9=B8?= =?UTF-8?q?=20=EC=A4=84=EB=A7=9E=EC=B6=A4=20=C2=B7=20=EB=A6=AC=EC=86=8C?= =?UTF-8?q?=EC=8A=A4=20=EA=B8=B0=EA=B0=84=20=EA=B8=80=20=C2=B7=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=ED=91=9C=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EB=A7=89=EB=8C=80=20=EB=91=90=20=EC=A4=84=20=C2=B7=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=EC=A4=84=20=EC=A0=95=EB=A0=AC=20(92-1=C2=B792-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 정보 칸 라벨 · 값 한 쌍씩 묶음 · 리소스 그래프 글이 고른 기간을 따름 · 프로젝트 표 가로 여백 줄임 - 화면 검증(ORCA) 권한 셋 · 밝음 · 어두움 · 390px 끝 — PLAN 92장 검증완료 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- B01_Dashboard/B01_Dashboard_UI_Company.ts | 21 ++++++++++-------- B01_Dashboard/B01_Dashboard_UI_Profile.ts | 4 +++- B01_Dashboard/B01_Dashboard_UI_ProjectsTab.ts | 1 + B01_Dashboard/B01_Dashboard_UI_Resources.ts | 2 +- B01_Dashboard/B01_Dashboard_UI_Style_Tabs.css | 22 ++++++++++++++++++- ui_template/ui_template_locale_b1_tabs.ts | 1 + 6 files changed, 39 insertions(+), 12 deletions(-) diff --git a/B01_Dashboard/B01_Dashboard_UI_Company.ts b/B01_Dashboard/B01_Dashboard_UI_Company.ts index b5582f3bf..6eed3b476 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Company.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Company.ts @@ -23,12 +23,14 @@ import { formatDate, L, runRequest } from "./B01_Dashboard_UI_Common"; import { createPagedTable } from "./B01_Dashboard_UI_Paging"; /** 라벨 + 값 한 칸 — 회사 정보 격자 */ -function infoItem(label: string, value: unknown): HTMLElement[] { +function infoItem(label: string, value: unknown): HTMLElement { + const item = document.createElement("div"); const term = document.createElement("dt"); term.textContent = label; const detail = document.createElement("dd"); detail.textContent = value == null || value === "" ? "-" : String(value); - return [term, detail]; + item.append(term, detail); + return item; } export function buildCompanyPanel(state: DashboardState): HTMLElement { @@ -54,15 +56,16 @@ export function buildCompanyPanel(state: DashboardState): HTMLElement { const info = document.createElement("dl"); info.className = "b01-dashboard__info"; info.append( - ...infoItem(L("B01_Dashboard_Table_Company"), company.name), - ...infoItem(L("B01_Dashboard_Field_BusinessNumber"), company.business_registration_number), - ...infoItem(L("B01_Co_Owner"), company.business_owner), - ...infoItem(L("B01_Co_Status"), company.business_status), - ...infoItem(L("B01_Dashboard_Field_Address"), company.business_address), - ...infoItem(L("B01_Co_Members"), company.user_count ?? 0), - ...infoItem(L("B01_Co_Projects"), company.project_count ?? 0), + infoItem(L("B01_Dashboard_Table_Company"), company.name), + infoItem(L("B01_Dashboard_Field_BusinessNumber"), company.business_registration_number), + infoItem(L("B01_Co_Owner"), company.business_owner), + infoItem(L("B01_Co_Status"), company.business_status), + infoItem(L("B01_Dashboard_Field_Address"), company.business_address), + infoItem(L("B01_Co_Members"), company.user_count ?? 0), + infoItem(L("B01_Co_Projects"), company.project_count ?? 0), ); const box = document.createElement("div"); + box.className = "b01-dashboard__stack"; box.append(info, wrap); // 회사 정보 수정 (2026-09-06 사용자 지시) — 관리자만 보인다. if (state.user.role !== "USER") { diff --git a/B01_Dashboard/B01_Dashboard_UI_Profile.ts b/B01_Dashboard/B01_Dashboard_UI_Profile.ts index faa4cc08b..c7b73650d 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Profile.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Profile.ts @@ -121,11 +121,13 @@ export function buildAccountInfo(user: DashboardUser): HTMLElement { [L("B01_Ac_AuthExpires"), formatDate(user.auth_expires_at)], ]; for (const [label, value] of rows) { + const item = document.createElement("div"); const term = document.createElement("dt"); term.textContent = label; const detail = document.createElement("dd"); detail.textContent = value; - info.append(term, detail); + item.append(term, detail); + info.append(item); } return info; } diff --git a/B01_Dashboard/B01_Dashboard_UI_ProjectsTab.ts b/B01_Dashboard/B01_Dashboard_UI_ProjectsTab.ts index 3e7dd69a8..cbdc2b657 100644 --- a/B01_Dashboard/B01_Dashboard_UI_ProjectsTab.ts +++ b/B01_Dashboard/B01_Dashboard_UI_ProjectsTab.ts @@ -96,6 +96,7 @@ export function buildProjectsTab(state: DashboardState, projects: ProjectItem[]) ); const progress = createPagedTable(progressHeaders(), progressRow, projects); + list.root.classList.add("b01-dashboard__paged--dense"); let filter: ProjectFilter = parseFilter(currentQuery().get("filter")); const query = createInputField({ label: L("B01_Pj_Search"), diff --git a/B01_Dashboard/B01_Dashboard_UI_Resources.ts b/B01_Dashboard/B01_Dashboard_UI_Resources.ts index 923573137..efba99f84 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Resources.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Resources.ts @@ -66,7 +66,7 @@ function buildResourceMetrics(resources: ResourceData | null): HTMLElement { function buildResourceChart(resources: ResourceData | null, days: number): HTMLElement { return buildTrendChart(resources, days, { - caption: L("B01_Dashboard_Resources_ChartCaption"), + caption: L("B01_Sy_ResourceCaption").replace("{days}", String(days)), ariaLabel: L("B01_Dashboard_Resources_ChartAria"), series: [ { name: L("B01_Dashboard_Metric_Cpu"), colorIndex: 0, pick: (h) => h.cpu_usage_percent }, diff --git a/B01_Dashboard/B01_Dashboard_UI_Style_Tabs.css b/B01_Dashboard/B01_Dashboard_UI_Style_Tabs.css index dd752a34e..51ba92b82 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style_Tabs.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Tabs.css @@ -66,11 +66,15 @@ gap: var(--spacing-8); } +.b01-dashboard__overview { + gap: var(--spacing-24); +} + /* 찾기 · 거르기 줄 */ .b01-dashboard__filters { display: flex; flex-wrap: wrap; - align-items: flex-end; + align-items: flex-start; gap: var(--spacing-16); margin-bottom: var(--spacing-16); } @@ -102,6 +106,12 @@ margin: 0 0 var(--spacing-16); } +.b01-dashboard__stack { + display: flex; + flex-direction: column; + gap: var(--spacing-16); +} + .b01-dashboard__info dt { color: var(--color-text-secondary); font-size: var(--text-caption); @@ -154,3 +164,13 @@ vertical-align: bottom; white-space: nowrap; } + +/* 칸이 많은 표(프로젝트) — 가로 여백을 줄이고 단계 막대가 두 줄 안에 들어오게 */ +.b01-dashboard__paged--dense .b01-dashboard__table th, +.b01-dashboard__paged--dense .b01-dashboard__table td { + padding: var(--spacing-8); +} + +.b01-dashboard__paged--dense .ui-workflow-layout__steps { + min-width: 400px; +} diff --git a/ui_template/ui_template_locale_b1_tabs.ts b/ui_template/ui_template_locale_b1_tabs.ts index 8084b4cb4..95d00d28f 100644 --- a/ui_template/ui_template_locale_b1_tabs.ts +++ b/ui_template/ui_template_locale_b1_tabs.ts @@ -77,6 +77,7 @@ export const ui_locales_b1_tabs = { /* --- 시스템 --- */ B01_Sy_Period: ["기간", "Period"], B01_Sy_Days: ["{days}일", "{days} days"], + B01_Sy_ResourceCaption: ["최근 {days}일 리소스 사용률", "Resource usage, last {days} days"], B01_Sy_ActiveTrend: ["활성 추이", "Activity trend"], B01_Sy_TrendCaption: ["활성 사용자 · 활성 프로젝트 수", "Active users and active projects"], B01_Sy_AllCompanies: ["전체 회사", "All companies"], From bcfcb974bfc1e218208ccf3f476e187cb6783067 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 14:10:11 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix(B07):=20=ED=95=84=EC=A7=80=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=83=81=EC=9E=90=20=EC=83=89=20=E2=80=94=20?= =?UTF-8?q?=EC=96=B4=EB=91=90=EC=9B=80=20=EB=B0=94=ED=83=95=EC=97=90?= =?UTF-8?q?=EC=84=9C=EB=8F=84=20=EC=9D=BD=ED=9E=88=EB=8A=94=20=EC=A4=91?= =?UTF-8?q?=EA=B0=84=20=EB=B0=A4=EC=83=89(93-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index ca03b718f..a88402caa 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -117,7 +117,7 @@ _COLORS = { "refuge": "#7ddc7d", "drainage": STRUCTURE_COLOR, "parcel": "#8c6b4f", - "parcel_info": "#5c4632", + "parcel_info": "#a8703f", # 밝음 · 어두움 바탕 둘 다 읽히는 중간 밤색 "emd": "#2f7d4f", "sgg": "#a63d3d", } From b43cda3ed80a855a9976aacf5fb59243de2d757a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 14:11:04 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat(B08):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=ED=91=9C=20=EB=B0=B0=EA=B4=80=20=EC=97=B4=20?= =?UTF-8?q?=3D=20=EC=84=9C=EB=B2=84=20=EC=B9=B8=20=E2=80=94=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=C2=B7=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D=20=C2=B7=20=EA=B7=9C=EA=B2=A9=203=EB=8B=A8=20+=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=C2=B7=20=EA=B3=84=20=EC=A4=84=20=C2=B7=20?= =?UTF-8?q?=EA=B4=80=20=EA=B3=B5=20=EB=A7=A8=20=EC=9C=97=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=20=EC=A1=B0=ED=95=A9=20=EA=B2=BD=EB=A1=9C(98-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- B08_Quantity/B08_Quantity_UI_Summary.ts | 110 +++++++++++++++++-- resources/tester/test_98_b08_pipe_summary.py | 11 ++ ui_template/ui_template_locale_b4.ts | 1 + 3 files changed, 110 insertions(+), 12 deletions(-) diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index b1d954caa..3686f449c 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -1,13 +1,16 @@ /* ============================================================================= * B08_Quantity_UI_Summary.ts - * 1탭 구조물 집계표(PLAN 61-1) — 줄 = 측점 · 열 = 저장된 구조물 조합 · 칸은 아직 비움. + * 1탭 구조물 집계표(PLAN 61-1) — 줄 = 측점 · 열 = 저장된 구조물 조합 · 맨 아래 계 줄. * 표는 공용 많은 자료 표(`ui_template_data_table` · PLAN 70-2) — 머리 · 측점 열 고정. * * 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만) * 머리 두 줄 = 조합 구분(M02 경로 첫 칸) · 조합 이름(나머지 경로) — `GET /api/m02/items?all=1` + * 배관(횡단배수관) 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98) — 머리 구조물 · 형식 · 규격 + * 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 다른 구조물 열은 조합 그대로 * 줄을 누르면 그 측점의 구조물도 모달(PLAN 95-3 · `B08_Quantity_UI_Drawing` 은 누를 때 `import()`) * ========================================================================== */ +import { API_BASE_URL } from "@config/config_frontend"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { loadRows as loadPickerRows } from "@ui/ui_template_item_picker"; import { pathOf, rowId, type PickerRow } from "@ui/ui_template_item_picker_data"; @@ -15,7 +18,7 @@ import { showToast } from "@ui/ui_template_elements"; import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { createDataTable } from "@ui/ui_template_data_table"; +import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; import { pipeStructureId } from "@ui/ui_template_item_picker_project"; import { stationLabel } from "../common_util/common_util_station"; import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item"; @@ -50,12 +53,74 @@ function note(text: string): HTMLElement { /** 머리 글 길이에 맞춘 열 폭 — 칸은 비어 있어 글이 폭을 정함. */ const widthOf = (label: string): number => Math.min(200, Math.max(96, label.length * 13 + 24)); +/** 서버 배관 집계(PLAN 98-1) — 열 머리 `group`(구조물 · 형식 · 규격) + `label`(단위) · 열마다 조합 id. */ +interface PipeColumn { + id: string; + group: string[]; + label: string; + combos: string[]; +} +interface PipeSummary { + columns: PipeColumn[]; + rows: Array<{ chainage_m: number; values: Record }>; + total: Record; + notes: string[]; +} + +async function fetchPipeSummary(projectId: string): Promise { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/quantity/summary`, { + credentials: "include", + }); + if (!response.ok) throw new Error(`${response.status} ${await response.text()}`); + return response.json(); +} + +const PIPE_TOP = "관 공"; +const stationKey = (station: number): number => Math.round(station * 100); +/** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */ +const placesOf = (values: number[]): number => + Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length)); +const longest = (texts: string[]): string => + texts.reduce((a, b) => (b.length > a.length ? b : a), ""); + +/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(M02 에 있으면 · 조합 잇기 98). */ +function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] { + const top = + summary.columns + .filter((column) => column.group[0] === PIPE_TOP) + .flatMap((column) => column.combos) + .map(comboPath) + .find((path) => !!path) ?? PIPE_TOP; + return summary.columns.map((column) => { + const group = column.group[0] === PIPE_TOP ? [top, ...column.group.slice(1)] : column.group; + const values = [ + ...summary.rows.map((row) => row.values[column.id]), + summary.total[column.id], + ].filter((value): value is number => typeof value === "number"); + return { + key: column.id, + label: column.label, + group, + kind: "number", + decimals: placesOf(values), + title: [...group, column.label].join(" › "), + width: widthOf(longest([...group.slice(1), column.label])), + }; + }); +} + function buildTable( stations: number[], columns: ComboColumn[], + pipe: PipeSummary | null, + comboPath: (id: string) => string | null, interval: number, onRowClick: (station: number, label: string) => void, ): HTMLElement { + const values = new Map((pipe?.rows ?? []).map((row) => [stationKey(row.chainage_m), row.values])); + const total: DataRow | null = pipe?.columns.length + ? { station: L("B08_Quantity_Summary_Total"), ...pipe.total } + : null; const table = createDataTable({ columns: [ { key: "station", label: L("B08_Quantity_Summary_Station"), width: 96 }, @@ -67,9 +132,14 @@ function buildTable( className: column.missing ? "is-missing" : undefined, width: widthOf(column.name), })), + ...(pipe ? pipeColumns(pipe, comboPath) : []), ], - // 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 - rows: stations.map((station) => ({ station: stationLabel(station, interval) })), + // 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값 + rows: stations.map((station) => ({ + station: stationLabel(station, interval), + ...values.get(stationKey(station)), + })), + total, onRowClick: (i) => onRowClick(stations[i], stationLabel(stations[i], interval)), frozen: 1, empty: "", @@ -99,7 +169,7 @@ async function openSheets( export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise { host.replaceChildren(note(L("B08_Quantity_Summary_Loading"))); try { - const [structures, pickerRows, pipes, interval] = await Promise.all([ + const [structures, pickerRows, pipes, interval, pipe] = await Promise.all([ fetchStructures(projectId), loadPickerRows(), // 배수 분석 전이면 관이 없음 — 구조물만으로 그림 @@ -108,6 +178,8 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin () => [], ), stationInterval(projectId).catch(() => 20), + // 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로 + fetchPipeSummary(projectId).catch((error: unknown) => String(error)), ]); const m02 = new Map(pickerRows.map((row) => [rowId(row), row])); const describe = (item: SummaryItem): ComboLabel | null => { @@ -116,17 +188,31 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin const [group = "", ...rest] = pathOf(row); return { group, name: rest.join(" ") || group || row.key }; }; - const things: PlacedItem[] = [ - ...structures.structures, - ...pipes.map((pipe) => ({ ...pipe, structure_id: pipeStructureId(pipe.chainage_m) })), - ]; + const comboPath = (id: string): string | null => { + const row = m02.get(id); + return row ? pathOf(row).join(" ") : null; + }; + const placed = pipes.map((point) => ({ + ...point, + structure_id: pipeStructureId(point.chainage_m), + })); + const things: PlacedItem[] = [...structures.structures, ...placed]; const stations = summaryStations(things); - const columns = summaryColumns(things, describe); + // 배관은 서버 집계 열(98) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부) + const others = [ + ...structures.structures, + ...placed.filter((point) => (point.facility ?? "pipe") !== "pipe"), + ]; + const columns = summaryColumns(others, describe); + const summary = typeof pipe === "string" ? null : pipe; const parts: HTMLElement[] = []; - if (!columns.length) parts.push(note(L("B08_Quantity_Summary_NoCombo"))); + if (typeof pipe === "string") parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${pipe}`)); + for (const text of summary?.notes ?? []) parts.push(note(text)); + if (!columns.length && !summary?.columns.length) + parts.push(note(L("B08_Quantity_Summary_NoCombo"))); parts.push( stations.length - ? buildTable(stations, columns, interval, (station, label) => + ? buildTable(stations, columns, summary, comboPath, interval, (station, label) => openSheets(projectId, label, combosAt(things, station), m02, describe), ) : note(L("B08_Quantity_Summary_NoStation")), diff --git a/resources/tester/test_98_b08_pipe_summary.py b/resources/tester/test_98_b08_pipe_summary.py index 9caf86083..be9f0c99a 100644 --- a/resources/tester/test_98_b08_pipe_summary.py +++ b/resources/tester/test_98_b08_pipe_summary.py @@ -213,3 +213,14 @@ def test_1본_길이는_프로젝트_층_변수(root, tmp_path, monkeypatch): def test_라우터_등록(): text = (Path(__file__).resolve().parents[2] / "main.py").read_text(encoding="utf-8") assert "app.include_router(b08_summary_router, dependencies=protected_with_company)" in text + + +def test_화면이_서버_칸을_그림(): + """98-4 — 화면은 서버 배관 열 · 칸 · 계만 그림(계산 없음) · 배관 조합 열은 서버 열로 바뀜.""" + base = Path(__file__).resolve().parents[2] + ui = (base / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + assert "/quantity/summary" in ui and "pipeColumns(pipe, comboPath)" in ui + assert '(point.facility ?? "pipe") !== "pipe"' in ui # 배관 조합 열 대신 서버 열 + assert "combosAt(things, station)" in ui # 모달은 배관 조합까지 그대로 + locale = (base / "ui_template/ui_template_locale_b4.ts").read_text(encoding="utf-8") + assert 'B08_Quantity_Summary_Total: ["계", "Total"]' in locale diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index 010dbce23..8f2f5cab4 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -58,4 +58,5 @@ export const ui_locales_b4 = { "놓인 구조물 · 배관이 없습니다.", "No placed structures or pipes.", ], + B08_Quantity_Summary_Total: ["계", "Total"], } as const;