Merge remote-tracking branches 'origin/sub_desktop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into main_laptop_1
This commit is contained in:
@@ -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) — 옛 둘 이상은 보이되 [추가]만 숨김. */
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ _COLORS = {
|
||||
"refuge": "#7ddc7d",
|
||||
"drainage": STRUCTURE_COLOR,
|
||||
"parcel": "#8c6b4f",
|
||||
"parcel_info": "#5c4632",
|
||||
"parcel_info": "#a8703f", # 밝음 · 어두움 바탕 둘 다 읽히는 중간 밤색
|
||||
"emd": "#2f7d4f",
|
||||
"sgg": "#a63d3d",
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<KeyboardEvent> = {}) =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, { kind?: string } | undefined>
|
||||
| undefined;
|
||||
return Object.values(meta ?? {}).some((stateMeta) => stateMeta?.kind === 'text');
|
||||
}
|
||||
|
||||
/** Enter 로 받을 Space — 수정키(Shift · Ctrl · Alt · Meta: 한/영 전환 등) · 길게 눌러 반복은 아님 */
|
||||
export const isPlainSpace = (
|
||||
evt: Pick<KeyboardEvent, 'key' | 'shiftKey' | 'ctrlKey' | 'altKey' | 'metaKey' | 'repeat'>
|
||||
): boolean =>
|
||||
evt.key === ' ' && !evt.shiftKey && !evt.ctrlKey && !evt.altKey && !evt.metaKey && !evt.repeat;
|
||||
|
||||
export class ImeSink {
|
||||
/** 조합 중인 글자 — 커서 옆 입력 칸에 미리 보인다 */
|
||||
public composing = '';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)
|
||||
@@ -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<string, number> }>;
|
||||
total: Record<string, number>;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
async function fetchPipeSummary(projectId: string): Promise<PipeSummary> {
|
||||
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<void> {
|
||||
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")),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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_"]
|
||||
# 측벽 높이 · 좌우 · 상하 · 바닥판 · 날개벽 치수 칸 없음
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
# -*- 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
|
||||
|
||||
|
||||
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
|
||||
@@ -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"],
|
||||
|
||||
@@ -58,4 +58,5 @@ export const ui_locales_b4 = {
|
||||
"놓인 구조물 · 배관이 없습니다.",
|
||||
"No placed structures or pipes.",
|
||||
],
|
||||
B08_Quantity_Summary_Total: ["계", "Total"],
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user