From a1069f270af7afc0621292088c03b30ae5e744d6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 19:32:09 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(529-1):=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=ED=8B=80=20=EC=9D=B8=EC=9E=90(=EC=95=84=EC=9D=B4=EC=BD=98=20?= =?UTF-8?q?=C2=B7=20[<][>]=20=EB=AC=B6=EC=9D=8C=20=C2=B7=20=EB=8F=84?= =?UTF-8?q?=EC=9B=80=EB=A7=90=20=EC=9D=B4=EB=A6=84)=20=C2=B7=20D=20=C2=B7?= =?UTF-8?q?=20E=20=EB=8B=A8=EA=B3=84=20=EB=B9=88=20=EA=B8=B8=20=EC=9D=BC?= =?UTF-8?q?=EA=B3=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createWorkflowLayout icons 인자(없으면 B 일곱 아이콘 그대로) - [?] [<] [>] 묶음 = B05~B09 · D02~D04 · E02~E05 · 묶음 안에서만 오감 · 설명서 이름 B0N/D0N/E0N - 서버 설명서 주소 D01~D04 · E01~E05 허용 · 우클릭 막기 · 돋보기 d · e 주소 - ROUTES D01~E05 · PLAN_STEP_ROUTES · STAB_STEP_ROUTES · A00_Common/group_workflow(빈 단계 = 틀 · 좌측 [?] 줄 · 단계 막대) - 페이지 폴더마다 draft/<폴더>_Route.ts · tsconfig 에 D0* · E0* · 문구 ui_template_locale_de - 시험: 280-2 대역에 D · E 묶음 · 54-2 D · E 설명서 길 Co-Authored-By: Claude Opus 5.5 --- A00_Common/group_workflow.ts | 60 +++++++++++++++++++ D02_PlanBase/draft/D02_PlanBase_Route.ts | 17 ++++++ .../draft/D03_PlanAnalysis_Route.ts | 17 ++++++ .../draft/D04_PlanConfirm_Route.ts | 17 ++++++ E02_StabGround/draft/E02_StabGround_Route.ts | 17 ++++++ .../draft/E03_StabSection_Route.ts | 17 ++++++ E04_Stab3D/draft/E04_Stab3D_Route.ts | 17 ++++++ E05_StabReport/draft/E05_StabReport_Route.ts | 17 ++++++ common_util/common_util_help_router.py | 2 +- config/config_frontend.ts | 25 ++++++++ resources/tester/helper_280_2_step_nav.cjs | 14 ++++- resources/tester/test_280_2_step_nav.py | 13 ++++ resources/tester/test_54_2_help_files.py | 6 +- tsconfig.json | 2 +- ui_template/ui_template_help.ts | 42 +++++++------ ui_template/ui_template_locale.ts | 3 + ui_template/ui_template_locale_de.ts | 21 +++++++ ui_template/ui_template_loupe.ts | 4 +- ui_template/ui_template_native_menu.ts | 4 +- ui_template/ui_template_workflow_layout.ts | 4 +- 20 files changed, 292 insertions(+), 27 deletions(-) create mode 100644 A00_Common/group_workflow.ts create mode 100644 D02_PlanBase/draft/D02_PlanBase_Route.ts create mode 100644 D03_PlanAnalysis/draft/D03_PlanAnalysis_Route.ts create mode 100644 D04_PlanConfirm/draft/D04_PlanConfirm_Route.ts create mode 100644 E02_StabGround/draft/E02_StabGround_Route.ts create mode 100644 E03_StabSection/draft/E03_StabSection_Route.ts create mode 100644 E04_Stab3D/draft/E04_Stab3D_Route.ts create mode 100644 E05_StabReport/draft/E05_StabReport_Route.ts create mode 100644 ui_template/ui_template_locale_de.ts diff --git a/A00_Common/group_workflow.ts b/A00_Common/group_workflow.ts new file mode 100644 index 000000000..4ece4cdaa --- /dev/null +++ b/A00_Common/group_workflow.ts @@ -0,0 +1,60 @@ +/* ============================================================================= + * group_workflow.ts + * D 예정노선 · E 안정성평가 단계 묶음(요구 345 · PLAN 529-1) — B05~B09 틀(`createWorkflowLayout`)을 + * 단계 목록 · 아이콘 · 주소 인자로 그대로 씀. 빈 단계 = 틀 · 좌측 패널([?] [<] [>]) · 단계 막대만. + * 페이지 길 = 각 폴더 `draft/<폴더>_Route.ts` · 본문이 생기면 그 폴더 페이지로 갈아 끼움. + * ========================================================================== */ + +import { PLAN_STEP_ROUTES, STAB_STEP_ROUTES, type RoutePath } from "@config/config_frontend"; +import { el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { navigateTo, type PageRenderer } from "./router"; + +interface GroupFlow { + routes: readonly RoutePath[]; + labels: readonly LocaleKey[]; + icons: readonly string[]; +} + +const FLOWS: readonly GroupFlow[] = [ + { + routes: PLAN_STEP_ROUTES, + labels: ["DE_Step_PlanBase", "DE_Step_PlanAnalysis", "DE_Step_PlanConfirm"], + icons: ["📍", "🧭", "✅"], + }, + { + routes: STAB_STEP_ROUTES, + labels: ["DE_Step_StabGround", "DE_Step_StabSection", "DE_Step_Stab3D", "DE_Step_StabReport"], + icons: ["🪨", "📐", "🧊", "📄"], + }, +]; + +/** 단계 틀을 세움 — mainContent · leftPanel 이 없으면 빈 단계 안내 · [?] 줄만 */ +export function mountGroupStep( + root: HTMLElement, + route: RoutePath, + parts: { mainContent?: HTMLElement; leftPanel?: HTMLElement } = {}, +): void { + const flow = FLOWS.find((f) => f.routes.includes(route)); + if (!flow) throw new Error(`단계 묶음 밖 주소: ${route}`); + const active = flow.routes.indexOf(route); + const layout = createWorkflowLayout({ + title: t(flow.labels[active]), + steps: flow.labels.map((key) => t(key)), + icons: flow.icons, + activeStep: active, + routes: flow.routes, + leftPanel: parts.leftPanel, + mainContent: + parts.mainContent ?? el("p", { className: "app-placeholder", text: t("DE_Step_Empty") }), + onStepClick: (_index, next) => navigateTo(next as RoutePath), + }); + root.append(layout.root); +} + +/** 빈 단계 페이지(길 선언의 load 가 부름) */ +export const groupStepPage = + (route: RoutePath): PageRenderer => + (root) => + mountGroupStep(root, route); diff --git a/D02_PlanBase/draft/D02_PlanBase_Route.ts b/D02_PlanBase/draft/D02_PlanBase_Route.ts new file mode 100644 index 000000000..1db951dd7 --- /dev/null +++ b/D02_PlanBase/draft/D02_PlanBase_Route.ts @@ -0,0 +1,17 @@ +/* ============================================================================= + * D02_PlanBase_Route.ts + * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.D02_PLAN_BASE, + workflow: true, + project: true, + footerless: true, + load: async () => + (await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.D02_PLAN_BASE), +}; diff --git a/D03_PlanAnalysis/draft/D03_PlanAnalysis_Route.ts b/D03_PlanAnalysis/draft/D03_PlanAnalysis_Route.ts new file mode 100644 index 000000000..00fe6d874 --- /dev/null +++ b/D03_PlanAnalysis/draft/D03_PlanAnalysis_Route.ts @@ -0,0 +1,17 @@ +/* ============================================================================= + * D03_PlanAnalysis_Route.ts + * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.D03_PLAN_ANALYSIS, + workflow: true, + project: true, + footerless: true, + load: async () => + (await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.D03_PLAN_ANALYSIS), +}; diff --git a/D04_PlanConfirm/draft/D04_PlanConfirm_Route.ts b/D04_PlanConfirm/draft/D04_PlanConfirm_Route.ts new file mode 100644 index 000000000..c59fad866 --- /dev/null +++ b/D04_PlanConfirm/draft/D04_PlanConfirm_Route.ts @@ -0,0 +1,17 @@ +/* ============================================================================= + * D04_PlanConfirm_Route.ts + * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.D04_PLAN_CONFIRM, + workflow: true, + project: true, + footerless: true, + load: async () => + (await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.D04_PLAN_CONFIRM), +}; diff --git a/E02_StabGround/draft/E02_StabGround_Route.ts b/E02_StabGround/draft/E02_StabGround_Route.ts new file mode 100644 index 000000000..13e8f23bb --- /dev/null +++ b/E02_StabGround/draft/E02_StabGround_Route.ts @@ -0,0 +1,17 @@ +/* ============================================================================= + * E02_StabGround_Route.ts + * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E02_STAB_GROUND, + workflow: true, + project: true, + footerless: true, + load: async () => + (await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.E02_STAB_GROUND), +}; diff --git a/E03_StabSection/draft/E03_StabSection_Route.ts b/E03_StabSection/draft/E03_StabSection_Route.ts new file mode 100644 index 000000000..4b3805d51 --- /dev/null +++ b/E03_StabSection/draft/E03_StabSection_Route.ts @@ -0,0 +1,17 @@ +/* ============================================================================= + * E03_StabSection_Route.ts + * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E03_STAB_SECTION, + workflow: true, + project: true, + footerless: true, + load: async () => + (await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.E03_STAB_SECTION), +}; diff --git a/E04_Stab3D/draft/E04_Stab3D_Route.ts b/E04_Stab3D/draft/E04_Stab3D_Route.ts new file mode 100644 index 000000000..0f7aa9118 --- /dev/null +++ b/E04_Stab3D/draft/E04_Stab3D_Route.ts @@ -0,0 +1,17 @@ +/* ============================================================================= + * E04_Stab3D_Route.ts + * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E04_STAB_3D, + workflow: true, + project: true, + footerless: true, + load: async () => + (await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.E04_STAB_3D), +}; diff --git a/E05_StabReport/draft/E05_StabReport_Route.ts b/E05_StabReport/draft/E05_StabReport_Route.ts new file mode 100644 index 000000000..62a5a5bb7 --- /dev/null +++ b/E05_StabReport/draft/E05_StabReport_Route.ts @@ -0,0 +1,17 @@ +/* ============================================================================= + * E05_StabReport_Route.ts + * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E05_STAB_REPORT, + workflow: true, + project: true, + footerless: true, + load: async () => + (await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.E05_STAB_REPORT), +}; diff --git a/common_util/common_util_help_router.py b/common_util/common_util_help_router.py index 1f9c4be92..f08a44ba8 100644 --- a/common_util/common_util_help_router.py +++ b/common_util/common_util_help_router.py @@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException router = APIRouter(prefix="/api/help", tags=["help"]) HELP_ROOT = Path(__file__).resolve().parent.parent / "resources" / "help" -PAGE_RE = re.compile(r"^B0[2-9]$") +PAGE_RE = re.compile(r"^(B0[2-9]|D0[1-4]|E0[1-5])$") def read_help(page: str, root: Path = HELP_ROOT) -> list[dict[str, str]]: diff --git a/config/config_frontend.ts b/config/config_frontend.ts index 01ddc0a75..9d295ec1c 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -120,8 +120,33 @@ export const ROUTES = { M01_MASTER_DATA: "m01-master-data", // 마스터 템플릿 — 표 양식 · 도면 양식(시스템 · 회사 · 개인 · 프로젝트 층). 화면은 로그인만 확인. M02_MASTER_TEMPLATE: "m02-master-template", + // D 예정노선 · E 안정성평가(요구 345) — 길 선언은 페이지 폴더 `draft/*_Route.ts` + D01_PLAN_REGISTER: "d01-plan-register", + D02_PLAN_BASE: "d02-plan-base", + D03_PLAN_ANALYSIS: "d03-plan-analysis", + D04_PLAN_CONFIRM: "d04-plan-confirm", + E01_STAB_REGISTER: "e01-stab-register", + E02_STAB_GROUND: "e02-stab-ground", + E03_STAB_SECTION: "e03-stab-section", + E04_STAB_3D: "e04-stab-3d", + E05_STAB_REPORT: "e05-stab-report", } as const; +/** D 예정노선 단계 주소(D02 → D04 · 등록 D01 은 단계 밖) */ +export const PLAN_STEP_ROUTES = [ + ROUTES.D02_PLAN_BASE, + ROUTES.D03_PLAN_ANALYSIS, + ROUTES.D04_PLAN_CONFIRM, +] as const; + +/** E 안정성평가 단계 주소(E02 → E05 · 생성 E01 은 단계 밖) */ +export const STAB_STEP_ROUTES = [ + ROUTES.E02_STAB_GROUND, + ROUTES.E03_STAB_SECTION, + ROUTES.E04_STAB_3D, + ROUTES.E05_STAB_REPORT, +] as const; + export type RouteKey = keyof typeof ROUTES; export type RoutePath = (typeof ROUTES)[RouteKey]; diff --git a/resources/tester/helper_280_2_step_nav.cjs b/resources/tester/helper_280_2_step_nav.cjs index 3e62d8c86..92ed1250a 100644 --- a/resources/tester/helper_280_2_step_nav.cjs +++ b/resources/tester/helper_280_2_step_nav.cjs @@ -115,7 +115,12 @@ const toasts = []; let role = "SYSTEM_ADMIN"; let help; const STUBS = { - "@config/config_frontend": { ROUTES, API_BASE_URL: "" }, + "@config/config_frontend": { + ROUTES, + API_BASE_URL: "", + PLAN_STEP_ROUTES: ["d02-plan-base", "d03-plan-analysis", "d04-plan-confirm"], + STAB_STEP_ROUTES: ["e02-stab-ground", "e03-stab-section", "e04-stab-3d", "e05-stab-report"], + }, "./ui_template_locale": { t: (key) => key }, "./ui_template_modal": { openModal: () => undefined }, "./ui_template_elements": { @@ -190,6 +195,13 @@ const ALL = [ "b09-print", "m01-master-data", "m02-master-template", + // 529-1 D · E 묶음 — 묶음 안에서만 [<] [>] · 등록(D01 · E01) = [?] 만 + "d01-plan-register", + "d02-plan-base", + "d04-plan-confirm", + "e01-stab-register", + "e02-stab-ground", + "e05-stab-report", ]; const rows = {}; for (const route of ALL) rows[route] = { helpRow: rowOf(route, true), plain: rowOf(route, false) }; diff --git a/resources/tester/test_280_2_step_nav.py b/resources/tester/test_280_2_step_nav.py index 7693b2b47..20a92d14c 100644 --- a/resources/tester/test_280_2_step_nav.py +++ b/resources/tester/test_280_2_step_nav.py @@ -32,6 +32,19 @@ def results(): return json.loads(proc.stdout) +def test_529_1_D_E_묶음_안에서만_앞뒤(results): + rows = results["rows"] + for route in ("d01-plan-register", "e01-stab-register"): + assert rows[route]["plain"]["labels"] == ["?", "저장"], route + for route in ("d02-plan-base", "d04-plan-confirm", "e02-stab-ground", "e05-stab-report"): + assert rows[route]["plain"]["labels"] == ["?", "<", ">", "저장"], route + # 묶음 첫 단계 [<] · 끝 단계 [>] 흐림(B 묶음과 따로) + assert rows["d02-plan-base"]["plain"]["disabled"][:3] == [False, True, False] + assert rows["d04-plan-confirm"]["plain"]["disabled"][:3] == [False, False, True] + assert rows["e02-stab-ground"]["plain"]["disabled"][:3] == [False, True, False] + assert rows["e05-stab-report"]["plain"]["disabled"][:3] == [False, False, True] + + @pytest.mark.parametrize("shape", ["helpRow", "plain"]) def test_다섯_주소만_물음_앞뒤_차례(results, shape): for route in FIVE: diff --git a/resources/tester/test_54_2_help_files.py b/resources/tester/test_54_2_help_files.py index 480fda24a..1bc48b728 100644 --- a/resources/tester/test_54_2_help_files.py +++ b/resources/tester/test_54_2_help_files.py @@ -28,11 +28,15 @@ def test_title_order_and_live_edit(): assert "고친 글" in read_help("B05", tmp_path)[1]["text"] -@pytest.mark.parametrize("page", ["B01", "B10", "../B05", "b05"]) +@pytest.mark.parametrize("page", ["B01", "B10", "../B05", "b05", "D05", "E06", "C01"]) def test_bad_page_refused(page): with pytest.raises(HTTPException): read_help(page) +def test_529_1_D_E_pages_open_empty(tmp_path): + assert read_help("D02", tmp_path) == [] and read_help("E05", tmp_path) == [] + + def test_root_is_repo_resources(): assert HELP_ROOT.parts[-2:] == ("resources", "help") diff --git a/tsconfig.json b/tsconfig.json index 30bf838b6..c2c101de7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,6 +34,6 @@ "@kninnug/constrainautor": ["config/node_modules/@kninnug/constrainautor"], } }, - "include": ["A00_Common", "A0*", "B0*", "B1*", "M0*", "Z0*", "ui_template", "config/**/*", "common_util"], + "include": ["A00_Common", "A0*", "B0*", "B1*", "M0*", "D0*", "E0*", "Z0*", "ui_template", "config/**/*", "common_util"], "exclude": ["0_old", "node_modules", "venv", "dist"] } diff --git a/ui_template/ui_template_help.ts b/ui_template/ui_template_help.ts index c8d7f13ec..002d0cb36 100644 --- a/ui_template/ui_template_help.ts +++ b/ui_template/ui_template_help.ts @@ -5,12 +5,12 @@ * 페이지 = 주소(#/b05-…)의 B0N · 먼저 여는 항목 = 좌측 패널에서 보이는 `[data-help-topic]` 중 마지막 * (지금 연 상자 · 구조물 종류) · 없으면 첫 파일(00_설명서). * 단추 자리 = `.ui-sidebar-actions` 안 `[data-help-row]`(없으면 그 줄 자신)의 맨 앞. - * [<] [>](요구 280-2) = [?] 바로 뒤 · B05~B09 다섯 주소만 · 누르면 `ui-step-nav` 를 올려 보냄 → + * [<] [>](요구 280-2) = [?] 바로 뒤 · 단계 묶음(B05~B09 · D02~D04 · E02~E05 · 529-1) 주소만 · 누르면 `ui-step-nav` 를 올려 보냄 → * 진행 단계 틀(`createWorkflowLayout`)이 이웃 단계 단추를 누름(저장 · 막힘은 각 페이지 `onStepClick`). * ========================================================================== */ import "./ui_template_help.css"; -import { API_BASE_URL, ROUTES } from "@config/config_frontend"; +import { API_BASE_URL, PLAN_STEP_ROUTES, ROUTES, STAB_STEP_ROUTES } from "@config/config_frontend"; import { createButton, el } from "./ui_template_elements"; import { openModal } from "./ui_template_modal"; @@ -23,18 +23,23 @@ interface HelpItem { const TEXT = { button: "?", tip: "설명서", title: "설명서", close: "닫기", empty: "설명 글 없음" }; const STEP_TEXT = { prev: "<", next: ">", prevTip: "이전 단계", nextTip: "다음 단계" }; -/** [<] [>] 를 붙이는 주소 — 목록으로 거름(b09-print · B03 · B04 · M01 · M02 뺌) */ -const STEP_NAV_ROUTES = [ - ROUTES.B05_PROFILE, - ROUTES.B06_SECTION, - ROUTES.B07_DESIGN_DETAIL, - ROUTES.B08_QUANTITY, - ROUTES.B09_ESTIMATION, -] as const; +/** [<] [>] 를 붙이는 주소 묶음 — 묶음 안에서만 오감(b09-print · B03 · B04 · M01 · M02 · D01 · E01 뺌) */ +const STEP_NAV_GROUPS: ReadonlyArray = [ + [ + ROUTES.B05_PROFILE, + ROUTES.B06_SECTION, + ROUTES.B07_DESIGN_DETAIL, + ROUTES.B08_QUANTITY, + ROUTES.B09_ESTIMATION, + ], + PLAN_STEP_ROUTES, + STAB_STEP_ROUTES, +]; +/** 설명서 폴더 이름 = 주소 머리(#/b05-… → B05 · #/d02-… → D02) */ function currentPage(): string { - const n = /^#\/b0(\d)/i.exec(location.hash)?.[1]; - return n ? `B0${n}` : ""; + const head = /^#\/([bde]0\d)/i.exec(location.hash)?.[1]; + return head ? head.toUpperCase() : ""; } function escapeHtml(text: string): string { @@ -186,24 +191,25 @@ function createStepNavButton(step: -1 | 1): HTMLButtonElement { return button; } -/** 지금 주소의 [<] [>] 차례 — 목록 밖이면 -1 */ -function stepNavIndex(): number { +/** 지금 주소의 묶음 · [<] [>] 차례 — 목록 밖이면 차례 -1 */ +function stepNavIndex(): { index: number; last: number } { const route = location.hash.replace(/^#\/?/, "").split("?")[0]; - return (STEP_NAV_ROUTES as readonly string[]).indexOf(route); + const group = STEP_NAV_GROUPS.find((routes) => routes.includes(route)) ?? []; + return { index: group.indexOf(route), last: group.length - 1 }; } /** 아래 단추 묶음에 [?] 를 한 번 — `[data-help-row]` 가 있으면 그 줄 · 없으면 묶음 자신의 맨 앞 */ export function placeHelpButton(actions: HTMLElement): void { if (actions.querySelector(".ui-help-btn")) return; const row = actions.querySelector("[data-help-row]") ?? actions; - if (stepNavIndex() < 0) return void row.prepend(createHelpButton()); + if (stepNavIndex().index < 0) return void row.prepend(createHelpButton()); const prev = createStepNavButton(-1); const next = createStepNavButton(1); // 첫 단계(B05) [<] · 마지막 단계(B09) [>] 흐림 — B05 ↔ B06 은 페이지를 다시 안 세우므로 주소마다 다시 맞춤 const sync = (): void => { - const index = stepNavIndex(); + const { index, last } = stepNavIndex(); prev.disabled = index <= 0; - next.disabled = index === STEP_NAV_ROUTES.length - 1; + next.disabled = index === last; }; const onHash = (): void => { if (prev.isConnected) sync(); diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 51b1be1e4..042d85609 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -14,6 +14,7 @@ * - ui_template_locale_b1.ts : B 그룹 전반부 (B01~B04) * - ui_template_locale_b2.ts : B 그룹 후반부 (B05~B07) * - ui_template_locale_b5.ts : B 그룹 끝 (B08~B11) + * - ui_template_locale_de.ts : D 예정노선 · E 안정성평가 · 대시보드 [+] 고르기 * * 이 파일의 export 시그니처는 분할 이전과 동일하므로 소비처 import 수정은 불필요하다. * ========================================================================== */ @@ -28,6 +29,7 @@ import { ui_locales_b3 } from "./ui_template_locale_b3"; import { ui_locales_b5 } from "./ui_template_locale_b5"; import { ui_locales_m1 } from "./ui_template_locale_m1"; import { ui_locales_m2 } from "./ui_template_locale_m2"; +import { ui_locales_de } from "./ui_template_locale_de"; /** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */ export const LANGUAGES = ["ko", "en"] as const; @@ -65,6 +67,7 @@ export const ui_locales = { ...ui_locales_b5, ...ui_locales_m1, ...ui_locales_m2, + ...ui_locales_de, } as const; export type LocaleKey = keyof typeof ui_locales; diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts new file mode 100644 index 000000000..046e4c4a2 --- /dev/null +++ b/ui_template/ui_template_locale_de.ts @@ -0,0 +1,21 @@ +/* ============================================================================= + * ui_template_locale_de.ts + * 다국어 사전 — D 예정노선 · E 안정성평가 그룹(요구 345) · 대시보드 [+] 고르기 + * + * 신규 문구는 해당 섹션 최하단에 추가. + * 이 파일은 사전 데이터만 담는다. 합성·언어 전환·t() 헬퍼는 ui_template_locale.ts 참조. + * ========================================================================== */ + +import type { LocaleEntry } from "./ui_template_locale_common"; + +export const ui_locales_de = { + /* --- 단계 막대 · 빈 단계 --- */ + DE_Step_PlanBase: ["기준 지정", "Base Points"], + DE_Step_PlanAnalysis: ["노선 분석", "Route Analysis"], + DE_Step_PlanConfirm: ["확정 · 내보내기", "Confirm · Export"], + DE_Step_StabGround: ["지반 조건", "Ground"], + DE_Step_StabSection: ["2D 해석", "2D Analysis"], + DE_Step_Stab3D: ["3D 해석", "3D Analysis"], + DE_Step_StabReport: ["보고서", "Report"], + DE_Step_Empty: ["준비 중인 단계입니다.", "This step is not ready yet."], +} as const satisfies Record; diff --git a/ui_template/ui_template_loupe.ts b/ui_template/ui_template_loupe.ts index a7768fb54..dc083b541 100644 --- a/ui_template/ui_template_loupe.ts +++ b/ui_template/ui_template_loupe.ts @@ -33,8 +33,8 @@ export const LOUPE_HOLD_MS = 400; export const LOUPE_REFRESH_MS = 500; /** 화면 끝과 원 사이 여백(px) — 테두리가 안 잘리게 */ export const LOUPE_EDGE = 4; -/** 붙일 주소 — B01~B09 · M01 · M02(A 영역 · 로그인 · B10 · B11 은 뺌) */ -export const LOUPE_ROUTE = /^(b0[1-9]|m0[12])-/; +/** 붙일 주소 — B01~B09 · M01 · M02 · D01~D04 · E01~E05(A 영역 · 로그인 · B10 · B11 은 뺌) */ +export const LOUPE_ROUTE = /^(b0[1-9]|m0[12]|d0[1-4]|e0[1-5])-/; const SVG_NS = "http://www.w3.org/2000/svg"; /** CAD 영역 — cad_host 가 끼운 상자와 iframe */ diff --git a/ui_template/ui_template_native_menu.ts b/ui_template/ui_template_native_menu.ts index d5da108d9..993c81901 100644 --- a/ui_template/ui_template_native_menu.ts +++ b/ui_template/ui_template_native_menu.ts @@ -7,8 +7,8 @@ * 드롭다운 초점 빼기 — 마우스로 고른 뒤 초점이 남으면 휠 · ↑↓ 가 값을 또 바꿈(요구 280-6). * ========================================================================== */ -/** 막을 주소 — B · M 영역(`b01-…` · `m02-…`) · A 영역은 그대로 */ -export const NATIVE_MENU_ROUTE = /^[bm]\d/; +/** 막을 주소 — B · M · D · E 영역(`b01-…` · `m02-…` · `d02-…`) · A 영역은 그대로 */ +export const NATIVE_MENU_ROUTE = /^[bmde]\d/; /** `win` 의 기본 메뉴를 막음 — `isOn()` 이 거짓이면(예: A 영역 주소) 그대로 둠 */ export function blockNativeMenu(win: Window, isOn: () => boolean): void { diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index 07ff4ba68..8d87be6b7 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -30,6 +30,8 @@ export interface WorkflowLayoutOptions { /** 막힌(NOT_STARTED) 단계라도 켤 칸 — 수 하나 또는 목록(B05 · B06 = 2 · 3 · 4 · 496-1) */ currentStage?: number | readonly number[]; routes?: readonly string[]; + /** 단계 아이콘 — 없으면 B 일곱 단계 아이콘(D · E 그룹은 제 목록 · 529-1) */ + icons?: readonly string[]; onStepClick?: (stepIndex: number, route: string) => void; /** 좌측 패널이 열리고 닫힐 때(처음 앉힘 포함) — 닫힌 동안 바뀐 안쪽을 열 때 맞춤(499-1). */ onOptionsOpenChange?: (isOpen: boolean) => void; @@ -212,7 +214,7 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa currentStage: options.currentStage, routes: options.routes, orientation: "vertical", - icons: WORKFLOW_STEP_ICONS, + icons: options.icons ?? WORKFLOW_STEP_ICONS, onStepClick: options.onStepClick, homeButton: true, }); From ff8a8bad089cc3fd91dacb63f0e8c75c2c44d92d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 19:32:25 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(528):=20=EC=8B=A0=EA=B7=9C=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EA=B7=B8=EB=A3=B9=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EB=B0=B1=EC=97=94=EB=93=9C=20=E2=80=94=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20=EC=B9=B8=20=C2=B7=20=EC=82=B0=EB=A6=BC=EC=B2=AD=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20=C2=B7=20D01=20=C2=B7=20E01=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20API=20=C2=B7=20=EA=B3=B5=EC=9C=A0=20GET=20=EB=A7=8C?= =?UTF-8?q?=20=C2=B7=20=EA=B0=80=EC=9E=85=20go.kr=20=3D=20=EC=82=B0?= =?UTF-8?q?=EB=A6=BC=EC=B2=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB 020(추가만): projects 종류 · 사업연도 끝 · 설계속도 · 원본 칸 · plan_route_years · plan_route_assignees · 회사 「산림청」 행 - is_forest_email · is_forest_service · /api/auth/session 에 is_forest_service - require_project_access 한 곳 — 지정 회사 = GET · HEAD 만 · 그 밖 403 - POST /api/d01/project(산림청만 · PLAN · 설계속도 기본 = resolve_design_speed) · GET /api/dashboard/plan-routes/assigned - GET /api/e01/source-candidates · POST /api/e01/project(B06 완료만 · STABILITY · 원본 칸 · 자료 복사는 528-4) - 종류별 단계 시드(PLAN 3 · STABILITY 4) · storage 허용 D0x · E0x · B01 목록에 project_kind - 가입 인증 — 메일 *.go.kr = 회사 산림청 · ACTIVE · 화면 회사 칸 「산림청」 잠금 표시 - 지킴 시험 test_528_project_groups.py Co-Authored-By: Claude Opus 5.5 --- A06_Login/A06_Login_Router.py | 2 + A07_Register/A07_Register_Router.py | 10 +- A07_Register/A07_Register_UI_Auth_Page.ts | 10 + B01_Dashboard/B01_Dashboard_Repository.py | 8 +- D01_PlanRegister/D01_PlanRegister_Router.py | 87 +++++++ E01_StabRegister/E01_StabRegister_Router.py | 87 +++++++ common_util/common_util_auth.py | 23 ++ common_util/common_util_auth_repository.py | 29 ++- common_util/common_util_project_kind.py | 102 ++++++++ common_util/common_util_workflow_state.py | 16 +- config/config_system.py | 9 + db_management/020_project_groups.sql | 59 +++++ main.py | 7 + resources/tester/test_528_project_groups.py | 258 ++++++++++++++++++++ 14 files changed, 692 insertions(+), 15 deletions(-) create mode 100644 D01_PlanRegister/D01_PlanRegister_Router.py create mode 100644 E01_StabRegister/E01_StabRegister_Router.py create mode 100644 common_util/common_util_project_kind.py create mode 100644 db_management/020_project_groups.sql create mode 100644 resources/tester/test_528_project_groups.py diff --git a/A06_Login/A06_Login_Router.py b/A06_Login/A06_Login_Router.py index 162fc091c..e4e1c4714 100644 --- a/A06_Login/A06_Login_Router.py +++ b/A06_Login/A06_Login_Router.py @@ -15,6 +15,7 @@ from common_util.common_util_auth import ( hash_device_token, hash_password, hash_user_agent, + is_forest_service, set_device_token_cookie, set_session_cookie, verify_password, @@ -173,6 +174,7 @@ async def current_session(session: dict = Depends(verify_session)): "role": session["role"], "company_id": session["company_id"], "is_master": session["is_master"], + "is_forest_service": is_forest_service(session), }, } diff --git a/A07_Register/A07_Register_Router.py b/A07_Register/A07_Register_Router.py index 51e49b619..a96b4f647 100644 --- a/A07_Register/A07_Register_Router.py +++ b/A07_Register/A07_Register_Router.py @@ -12,6 +12,7 @@ from common_util.common_util_auth import ( hash_device_token, hash_password, hash_user_agent, + is_forest_email, set_device_token_cookie, verify_password, ) @@ -84,17 +85,20 @@ async def verify_registration(payload: RegisterVerifyRequest, request: Request): ): raise HTTPException(status_code=400, detail="인증 코드가 유효하지 않습니다.") await consume_otp(otp["id"]) - await complete_registration(user["id"], bool(user["is_master"])) + # 메일 *.go.kr = 회사 「산림청」 고정 — 화면이 보낸 회사 값은 받지 않음(요구 347-8). + account_status = await complete_registration( + user["id"], bool(user["is_master"]), forest=is_forest_email(user["email"]) + ) # 가입 인증한 브라우저를 신뢰 등록하여 첫 로그인 시 OTP를 생략한다. agent = request.headers.get("user-agent", "unknown")[:1000] device_token = generate_device_token() await trust_device(user["id"], hash_device_token(device_token), hash_user_agent(agent)) # 회원가입 시점에는 회사가 없으므로(NO_COMPANY) 마스터 알림은 발송하지 않는다. - # 회사 생성/연결은 로그인 후 B01_Dashboard에서 진행한다. + # 회사 생성/연결은 로그인 후 B01_Dashboard에서 진행한다(산림청 메일은 위에서 붙음). response = JSONResponse( { "status": "success", - "account_status": "NO_COMPANY", + "account_status": account_status, } ) set_device_token_cookie(response, device_token) diff --git a/A07_Register/A07_Register_UI_Auth_Page.ts b/A07_Register/A07_Register_UI_Auth_Page.ts index 7e8670f51..4dd8a8097 100644 --- a/A07_Register/A07_Register_UI_Auth_Page.ts +++ b/A07_Register/A07_Register_UI_Auth_Page.ts @@ -13,6 +13,7 @@ import { requestRegistration, verifyRegistration } from "./A07_Register_Api_Fetc import "./A07_Register_UI_Style.css"; const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex]; +const FOREST_COMPANY_NAME = "산림청"; interface AgreementField { root: HTMLElement; @@ -80,6 +81,14 @@ export function renderA07Register(root: HTMLElement): void { type: "text", }); const email = createInputField({ label: L("A07_Register_Field_Email"), type: "email" }); + // 메일 *.go.kr = 회사 「산림청」 고정(요구 347-8) — 서버도 같은 규칙으로 붙임. + const company = createInputField({ label: L("A07_Register_Field_Company"), type: "text" }); + company.input.value = FOREST_COMPANY_NAME; + company.input.readOnly = true; + company.root.hidden = true; + email.input.addEventListener("input", () => { + company.root.hidden = !email.input.value.trim().toLowerCase().endsWith(".go.kr"); + }); const phone = createInputField({ label: L("A07_Register_Field_Phone"), type: "tel" }); const password = createInputField({ label: L("A07_Register_Field_Password"), type: "password" }); const passwordConfirm = createInputField({ @@ -132,6 +141,7 @@ export function renderA07Register(root: HTMLElement): void { name.root, position.root, email.root, + company.root, phone.root, password.root, passwordConfirm.root, diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 8907556f1..6fec3759c 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -193,7 +193,7 @@ async def list_user_projects(user_id: int) -> list[dict[str, Any]]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( - """SELECT id, company_id, name, region, road_type, project_year, + """SELECT id, company_id, name, region, road_type, project_kind, project_year, estimated_length_m, route_start_m, route_end_m, memo, status, updated_at, created_at, client_org, project_number, work_amount, design_date, @@ -210,7 +210,8 @@ async def list_company_projects(company_id: int) -> list[dict[str, Any]]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( - """SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year, + """SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_kind, + p.project_year, p.estimated_length_m, p.route_start_m, p.route_end_m, p.memo, p.status, p.updated_at, p.created_at, p.client_org, p.project_number, p.work_amount, p.design_date, @@ -229,7 +230,8 @@ async def list_all_projects() -> list[dict[str, Any]]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( - """SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year, + """SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_kind, + p.project_year, p.estimated_length_m, p.route_start_m, p.route_end_m, p.memo, p.status, p.updated_at, p.created_at, p.client_org, p.project_number, p.work_amount, p.design_date, diff --git a/D01_PlanRegister/D01_PlanRegister_Router.py b/D01_PlanRegister/D01_PlanRegister_Router.py new file mode 100644 index 000000000..ce81164b8 --- /dev/null +++ b/D01_PlanRegister/D01_PlanRegister_Router.py @@ -0,0 +1,87 @@ +"""D01 예정노선 등록 API · 대시보드 「받은 예정노선」 (요구 345 · 요구서 3-1 · 2-2).""" + +from typing import Any + +import aiomysql +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field + +from B05_Profile.B05_Profile_Engine_Grade import resolve_design_speed +from B05_Profile.B05_Profile_PageSettings import grade_class_of +from common_util.common_util_auth import is_forest_service, require_company +from common_util.common_util_project_kind import create_kind_project +from config.config_db import get_db_pool + +router = APIRouter(prefix="/api/d01", tags=["D01_PlanRegister"]) +dashboard_router = APIRouter(prefix="/api/dashboard", tags=["D01_PlanRegister"]) + + +class CreatePlanRequest(BaseModel): + project_name: str = Field(..., min_length=1, max_length=255) + project_year: int = Field(..., ge=2000, le=2100) + project_year_end: int | None = Field(default=None, ge=2000, le=2100) + region: str = Field(..., min_length=1, max_length=100) + road_type: str = Field(..., pattern="^(main|fire|work)$") + design_speed_kph: int | None = None + start_chainage_m: float | None = Field(default=None, ge=0) + end_chainage_m: float | None = Field(default=None, ge=0) + + +@router.post("/project") +async def post_plan_project( + request: Request, + payload: CreatePlanRequest, + session: dict[str, Any] = Depends(require_company), +) -> dict[str, str]: + """예정노선 프로젝트 생성 — 산림청(시스템관리자 · *.go.kr)만.""" + if not is_forest_service(session): + raise HTTPException(status_code=403, detail="예정노선은 산림청만 만들 수 있습니다.") + if session.get("company_id") is None: + raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.") + year_end = payload.project_year_end or payload.project_year + if year_end < payload.project_year: + raise HTTPException(status_code=400, detail="사업연도 끝이 시작보다 앞입니다.") + start_m, end_m = payload.start_chainage_m, payload.end_chainage_m + if start_m is not None and end_m is not None and start_m >= end_m: + raise HTTPException( + status_code=400, detail="노선 시작 누가거리는 종료 누가거리보다 작아야 합니다." + ) + speed = resolve_design_speed(grade_class_of(payload.road_type, {}), payload.design_speed_kph) + project_id = await create_kind_project( + request=request, + user_id=int(session["user_id"]), + company_id=int(session["company_id"]), + kind="PLAN", + name=payload.project_name.strip(), + region=payload.region.strip(), + road_type=payload.road_type, + project_year=payload.project_year, + project_year_end=year_end, + design_speed_kph=speed, + route_start_m=start_m, + route_end_m=end_m, + ) + return {"project_id": project_id} + + +@dashboard_router.get("/plan-routes/assigned") +async def assigned_plan_routes( + session: dict[str, Any] = Depends(require_company), +) -> dict[str, Any]: + """우리 회사에 지정된 예정노선 연도 — 보기 · 연결 상태.""" + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute( + """SELECT a.project_id, p.name AS project_name, a.year, a.linked_project_id + FROM plan_route_assignees a + JOIN projects p ON p.id = a.project_id AND p.deleted_at IS NULL + WHERE a.company_id = %s + ORDER BY p.name, a.year""", + (session.get("company_id"),), + ) + rows = await cursor.fetchall() + # ponytail: update_available 자리만 — 지문 비교는 531-3. + routes = [{**r, "update_available": False} for r in rows] + return {"status": "success", "routes": routes} diff --git a/E01_StabRegister/E01_StabRegister_Router.py b/E01_StabRegister/E01_StabRegister_Router.py new file mode 100644 index 000000000..0d89c5822 --- /dev/null +++ b/E01_StabRegister/E01_StabRegister_Router.py @@ -0,0 +1,87 @@ +"""E01 안정성평가 생성 API (요구 345 · 347 · 요구서 4-1). + +대상 = 같은 회사 임도(ROAD) 중 B06 횡단 단계(stage 3) 완료. 자료 복사는 528-4 — 지금은 행만. +""" + +from typing import Any + +import aiomysql +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field + +from common_util.common_util_auth import require_company +from common_util.common_util_project_kind import create_kind_project +from config.config_db import get_db_pool + +router = APIRouter(prefix="/api/e01", tags=["E01_StabRegister"]) + +_SOURCE_SQL = """ + SELECT p.id AS project_id, p.name, p.region, p.road_type, p.project_year, + DATE_FORMAT(p.updated_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS updated_at, + s.state = 'COMPLETE' AS section_complete + FROM projects p + LEFT JOIN project_workflow_stages s ON s.project_id = p.id AND s.stage_no = 3 + WHERE p.company_id = %s AND p.project_kind = 'ROAD' AND p.deleted_at IS NULL +""" + + +class CreateStabRequest(BaseModel): + project_name: str | None = Field(default=None, max_length=255) + source_project_id: str = Field(..., min_length=36, max_length=36) + + +async def _sources(company_id: Any, project_id: str | None = None) -> list[dict[str, Any]]: + sql, args = _SOURCE_SQL, [company_id] + if project_id: + sql, args = sql + " AND p.id = %s", [company_id, project_id] + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute(sql + " ORDER BY p.updated_at DESC", tuple(args)) + return list(await cursor.fetchall()) + + +@router.get("/source-candidates") +async def source_candidates( + session: dict[str, Any] = Depends(require_company), +) -> dict[str, Any]: + """B06 까지 끝난 같은 회사 임도 프로젝트.""" + rows = await _sources(session.get("company_id")) + projects = [ + {k: v for k, v in r.items() if k != "section_complete"} + for r in rows + if r["section_complete"] + ] + return {"status": "success", "projects": projects} + + +@router.post("/project") +async def post_stab_project( + request: Request, + payload: CreateStabRequest, + session: dict[str, Any] = Depends(require_company), +) -> dict[str, str]: + """안정성평가 프로젝트 생성 — 원본 B06 미완료면 400.""" + company_id = session.get("company_id") + rows = await _sources(company_id, payload.source_project_id) if company_id else [] + if not rows: + raise HTTPException(status_code=404, detail="같은 회사 임도 프로젝트가 아닙니다.") + source = rows[0] + if not source["section_complete"]: + raise HTTPException( + status_code=400, detail="원본 임도의 횡단 설계(B06)가 끝나지 않았습니다." + ) + name = (payload.project_name or "").strip() or f"{source['name']} 안정성평가" + project_id = await create_kind_project( + request=request, + user_id=int(session["user_id"]), + company_id=int(company_id), + kind="STABILITY", + name=name, + region=source["region"], + road_type=source["road_type"], + project_year=source["project_year"], + source_project_id=source["project_id"], + ) + return {"project_id": project_id} diff --git a/common_util/common_util_auth.py b/common_util/common_util_auth.py index 89d314aaa..c8c994c10 100644 --- a/common_util/common_util_auth.py +++ b/common_util/common_util_auth.py @@ -148,6 +148,16 @@ async def get_optional_session(request: Request) -> dict[str, Any] | None: return None +def is_forest_email(email: str | None) -> bool: + """메일이 *.go.kr(대소문자 무시) — 가입 때 회사 「산림청」 고정.""" + return bool(email) and email.strip().lower().endswith(".go.kr") + + +def is_forest_service(session: dict[str, Any]) -> bool: + """산림청 = 시스템관리자 또는 메일 *.go.kr — 예정노선 생성 · 고침 권한.""" + return session.get("role") == "SYSTEM_ADMIN" or is_forest_email(session.get("email")) + + async def require_master(session: dict[str, Any] = Depends(verify_session)) -> dict[str, Any]: if not session["is_master"] and session["role"] != "SYSTEM_ADMIN": raise HTTPException(status_code=403, detail="마스터 권한이 필요합니다.") @@ -196,6 +206,19 @@ async def require_project_access( ) row = await cursor.fetchone() + if row and row[0] != session["company_id"]: + # 예정노선을 지정받은 회사 = 보기만(요구 345 · GET 만 통과). + await cursor.execute( + "SELECT 1 FROM plan_route_assignees" + " WHERE project_id = %s AND company_id = %s LIMIT 1", + (str(project_id), session["company_id"]), + ) + shared = await cursor.fetchone() + if shared: + if request.method in ("GET", "HEAD"): + return session + raise HTTPException(status_code=403, detail="보기만 허용된 예정노선입니다.") + if not row: raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") if row[0] != session["company_id"]: diff --git a/common_util/common_util_auth_repository.py b/common_util/common_util_auth_repository.py index 919cd8856..1b0c3ee97 100644 --- a/common_util/common_util_auth_repository.py +++ b/common_util/common_util_auth_repository.py @@ -148,15 +148,32 @@ async def consume_otp(otp_id: int) -> None: await connection.commit() -async def complete_registration(user_id: int, is_master: bool) -> None: +async def complete_registration(user_id: int, is_master: bool, forest: bool = False) -> str: + """메일 인증 끝 — 계정 상태를 돌려줌. forest(*.go.kr) = 회사 「산림청」 고정 · ACTIVE.""" pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute( - """UPDATE users SET status = 'NO_COMPANY', last_email_verified_at = CURRENT_TIMESTAMP - WHERE id = %s""", - (user_id,), - ) + company = None + if forest: + await cursor.execute( + "SELECT id FROM companies WHERE name = '산림청' AND deleted_at IS NULL" + ) + company = await cursor.fetchone() + if company: + await cursor.execute( + """UPDATE users SET status = 'ACTIVE', company_id = %s, role = 'USER', + last_email_verified_at = CURRENT_TIMESTAMP + WHERE id = %s""", + (company[0], user_id), + ) + else: + await cursor.execute( + """UPDATE users SET status = 'NO_COMPANY', + last_email_verified_at = CURRENT_TIMESTAMP + WHERE id = %s""", + (user_id,), + ) await connection.commit() + return "ACTIVE" if company else "NO_COMPANY" async def company_master_email(company_id: int) -> str | None: diff --git a/common_util/common_util_project_kind.py b/common_util/common_util_project_kind.py new file mode 100644 index 000000000..d4d4e997d --- /dev/null +++ b/common_util/common_util_project_kind.py @@ -0,0 +1,102 @@ +"""종류별 프로젝트 행 만들기 — D 예정노선 · E 안정성평가(요구 345). + +임도설계(ROAD)는 B02 그대로. 저장소 경로 · 초기 폴더는 B02 함수를 그대로 씀. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import uuid4 + +from B02_ProjRegister.B02_ProjRegister_Repository import ( + _build_project_storage, + _initialize_project_storage, +) +from common_util.common_util_audit import record_audit +from common_util.common_util_storage import get_project_stage_path +from common_util.common_util_workflow_state import initialize_project_stages +from config.config_db import get_db_pool + +KIND_STAGE_DIRS = { + "PLAN": ("D02_PlanBase", "D03_PlanAnalysis", "D04_PlanConfirm"), + "STABILITY": ("E02_StabGround", "E03_StabSection", "E04_Stab3D", "E05_StabReport"), +} + + +async def create_kind_project( + *, + request: Any | None, + user_id: int, + company_id: int, + kind: str, + name: str, + region: str | None, + road_type: str | None, + project_year: int | None, + project_year_end: int | None = None, + design_speed_kph: int | None = None, + route_start_m: float | None = None, + route_end_m: float | None = None, + source_project_id: str | None = None, +) -> str: + """projects 행 + 참여자 + 종류별 단계 + 저장소 폴더 → 새 project_id.""" + project_id = str(uuid4()) + storage_path, project_root = _build_project_storage(company_id, user_id, project_id) + now = datetime.utcnow() + + async with get_db_pool().acquire() as connection, connection.cursor() as cursor: + try: + await connection.begin() + await cursor.execute( + """ + INSERT INTO projects ( + id, user_id, company_id, name, region, road_type, project_kind, + project_year, project_year_end, design_speed_kph, + route_start_m, route_end_m, status, crs_epsg, storage_path, + source_project_id, source_copied_at, created_at, updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s, + %s, %s, %s, %s) + """, + ( + project_id, + user_id, + company_id, + name, + region, + road_type, + kind, + project_year, + project_year_end, + design_speed_kph, + route_start_m, + route_end_m, + storage_path, + source_project_id, + now if source_project_id else None, + now, + now, + ), + ) + await cursor.execute( + "INSERT IGNORE INTO project_members (project_id, user_id) VALUES (%s, %s)", + (project_id, user_id), + ) + await initialize_project_stages(cursor, project_id, kind) + await record_audit( + cursor, + actor_id=user_id, + action="PROJECT_CREATE", + resource_type="project", + resource_ref=project_id, + request=request, + ) + _initialize_project_storage(project_root, project_id) + for stage_dir in KIND_STAGE_DIRS[kind]: + get_project_stage_path(str(project_root), stage_dir) + await connection.commit() + except Exception: + await connection.rollback() + raise + return project_id diff --git a/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py index f3eea9bdd..3c0c4c7d5 100644 --- a/common_util/common_util_workflow_state.py +++ b/common_util/common_util_workflow_state.py @@ -20,9 +20,19 @@ STAGE_KEYS = [ ] -async def initialize_project_stages(cursor: aiomysql.DictCursor, project_id: str) -> None: - """프로젝트 생성 시 7개의 단계를 NOT_STARTED 상태로 시드한다.""" - for stage_no, stage_key in enumerate(STAGE_KEYS): +# 종류별 단계(요구 345) — ROAD = B03~B09 · PLAN = D02~D04 · STABILITY = E02~E05. +STAGE_KEYS_BY_KIND = { + "ROAD": STAGE_KEYS, + "PLAN": ["PLAN_BASE", "PLAN_ANALYSIS", "PLAN_CONFIRM"], + "STABILITY": ["STAB_GROUND", "STAB_SECTION", "STAB_3D", "STAB_REPORT"], +} + + +async def initialize_project_stages( + cursor: aiomysql.DictCursor, project_id: str, kind: str = "ROAD" +) -> None: + """프로젝트 생성 시 종류별 단계를 NOT_STARTED 상태로 시드한다.""" + for stage_no, stage_key in enumerate(STAGE_KEYS_BY_KIND[kind]): await cursor.execute( """ INSERT INTO project_workflow_stages ( diff --git a/config/config_system.py b/config/config_system.py index 364da6e9d..2fa29bb33 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -211,6 +211,15 @@ PROJECT_STORAGE_STAGE_DIRS = frozenset( "B07_DesignDetail", "B08_Quantity", "B09_Estimation", + "D01_PlanRegister", + "D02_PlanBase", + "D03_PlanAnalysis", + "D04_PlanConfirm", + "E01_StabRegister", + "E02_StabGround", + "E03_StabSection", + "E04_Stab3D", + "E05_StabReport", } ) diff --git a/db_management/020_project_groups.sql b/db_management/020_project_groups.sql new file mode 100644 index 000000000..e32d7bc4c --- /dev/null +++ b/db_management/020_project_groups.sql @@ -0,0 +1,59 @@ +-- 020_project_groups.sql +-- 신규 프로젝트 그룹 — 종류 · 예정노선 연도 구간 · 지정 업체 · 산림청 회사 (요구 345 · 347-8) +-- +-- 추가만 함 — 기존 행 = ROAD(임도설계) · 기존 칸 · 동작 그대로. +-- 예정노선 시작 · 종료 누가거리는 기존 route_start_m · route_end_m 을 씀. + +USE aislo_db; + +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS project_kind VARCHAR(20) NOT NULL DEFAULT 'ROAD' + COMMENT 'ROAD 임도설계 · PLAN 예정노선 · EROSION 사방설계 · STABILITY 안정성평가' AFTER road_type, + ADD COLUMN IF NOT EXISTS project_year_end INT NULL + COMMENT '사업연도 범위 끝(예정노선) — 시작은 project_year' AFTER project_year, + ADD COLUMN IF NOT EXISTS design_speed_kph INT NULL + COMMENT '설계속도(km/h · 예정노선 등록값)' AFTER project_year_end, + ADD COLUMN IF NOT EXISTS source_project_id CHAR(36) NULL + COMMENT '원본 프로젝트(E = 임도 · 임도 = 예정노선)', + ADD COLUMN IF NOT EXISTS source_copied_at DATETIME NULL COMMENT '원본 복사 시각', + ADD COLUMN IF NOT EXISTS source_fingerprint CHAR(40) NULL COMMENT '복사 때 원본 지문(sha1)'; + +CREATE TABLE IF NOT EXISTS plan_route_years ( + project_id CHAR(36) NOT NULL COMMENT 'projects.id (PLAN)', + year INT NOT NULL, + start_x DOUBLE NULL, + start_y DOUBLE NULL, + end_x DOUBLE NULL, + end_y DOUBLE NULL, + epsg INT NULL, + route_json JSON NULL COMMENT '분석 결과 노선', + fingerprint CHAR(40) NULL COMMENT '노선 내용 지문(sha1)', + confirmed_at DATETIME NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (project_id, year), + CONSTRAINT fk_plan_route_years_project FOREIGN KEY (project_id) + REFERENCES projects (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='예정노선 연도 구간'; + +CREATE TABLE IF NOT EXISTS plan_route_assignees ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id CHAR(36) NOT NULL COMMENT 'projects.id (PLAN)', + year INT NOT NULL, + company_id INT NULL COMMENT '등록 회사 — NULL 이면 수기 업체', + company_name VARCHAR(255) NULL, + email VARCHAR(255) NULL, + notified_at DATETIME NULL, + linked_project_id CHAR(36) NULL COMMENT '업체가 연결한 임도 프로젝트', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_plan_route_assignees_project (project_id, year), + KEY idx_plan_route_assignees_company (company_id), + CONSTRAINT fk_plan_route_assignees_project FOREIGN KEY (project_id) + REFERENCES projects (id) ON DELETE CASCADE, + CONSTRAINT fk_plan_route_assignees_company FOREIGN KEY (company_id) + REFERENCES companies (id) ON DELETE SET NULL, + CONSTRAINT fk_plan_route_assignees_linked FOREIGN KEY (linked_project_id) + REFERENCES projects (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='예정노선 연도별 지정 업체'; + +-- 산림청 회사 — 메일 *.go.kr 가입자가 자동으로 붙음. +INSERT IGNORE INTO companies (name, company_code, status) VALUES ('산림청', 'FOREST', 'ACTIVE'); diff --git a/main.py b/main.py index d9042e54b..f58c276b6 100644 --- a/main.py +++ b/main.py @@ -72,6 +72,9 @@ from B08_Quantity.B08_Quantity_Router_Summary import router as b08_summary_route from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router from B08_Quantity.B08_Quantity_Router_Milestone import router as b08_milestone_router from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router +from D01_PlanRegister.D01_PlanRegister_Router import dashboard_router as d01_dashboard_router +from D01_PlanRegister.D01_PlanRegister_Router import router as d01_plan_register_router +from E01_StabRegister.E01_StabRegister_Router import router as e01_stab_register_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 @@ -449,6 +452,10 @@ app.include_router(a09_security_router) app.include_router(b01_dashboard_router) app.include_router(b02_proj_register_router) protected = [Depends(verify_session)] +# 신규 그룹 등록(요구 345) — 권한은 길 안에서(산림청 · 회사). +app.include_router(d01_plan_register_router, dependencies=protected) +app.include_router(d01_dashboard_router, dependencies=protected) +app.include_router(e01_stab_register_router, dependencies=protected) # 프로젝트 경로가 붙는 라우터는 그 프로젝트가 내 회사 것인지도 함께 본다 # (2026-09-01 — 지금까지 id만 알면 남의 회사 자료에 닿았다). protected_with_company = [ diff --git a/resources/tester/test_528_project_groups.py b/resources/tester/test_528_project_groups.py new file mode 100644 index 000000000..b88997378 --- /dev/null +++ b/resources/tester/test_528_project_groups.py @@ -0,0 +1,258 @@ +"""528-1 · 3 · 5 신규 프로젝트 그룹 공통(요구 345 · 347-8). + +- 산림청 판정 — *.go.kr(대소문자 · 하위 도메인) · 시스템관리자 · 아닌 메일 +- D01 생성 — 일반 사용자 403 · 산림청 = PLAN · 설계속도 기본 · 연도 끝 기본 +- E01 생성 — 원본 B06 미완료 400 · 남의 것 404 · 완료 = STABILITY + 원본 칸 +- 공유 판정 — 지정 회사 GET 200 · PUT 403 · 미지정 403 · 같은 회사 그대로 +- 가입 — go.kr = 회사 「산림청」 · ACTIVE · 화면이 보낸 회사 값 무시 · 다른 메일 = NO_COMPANY +- 종류별 단계 시드 · storage 허용 목록 +""" + +import asyncio + +import pytest +from fastapi import HTTPException + +from A07_Register import A07_Register_Router as reg_router +from A07_Register.A07_Register_Schema import RegisterRequest +from common_util import common_util_auth as auth +from common_util import common_util_auth_repository as auth_repo +from common_util.common_util_workflow_state import initialize_project_stages +from config.config_system import PROJECT_STORAGE_STAGE_DIRS +from D01_PlanRegister import D01_PlanRegister_Router as d01 +from E01_StabRegister import E01_StabRegister_Router as e01 + + +class FakeCursor: + """SQL 조각 → 돌려줄 한 줄. 실행한 SQL · 인자를 쌓음.""" + + def __init__(self, answers, log): + self.answers, self.log, self.last = answers, log, None + + async def execute(self, sql, args=None): + self.log.append((" ".join(sql.split()), args)) + self.last = next((v for k, v in self.answers.items() if k in sql), None) + + async def fetchone(self): + return self.last + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + +class FakePool: + def __init__(self, answers=None): + self.answers, self.log = answers or {}, [] + + def acquire(self): + return self + + def cursor(self, *a): + return FakeCursor(self.answers, self.log) + + async def commit(self): + self.log.append(("commit", None)) + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + +class Req: + def __init__(self, method="GET", project_id="p1"): + self.method, self.path_params = method, {"project_id": project_id} + + +USER = {"user_id": 9, "role": "USER", "company_id": 1, "email": "a@gmail.com"} +FOREST = {"user_id": 8, "role": "USER", "company_id": 4, "email": "Kim@Forest.GO.KR"} +ADMIN = {"user_id": 1, "role": "SYSTEM_ADMIN", "company_id": 1, "email": "x@aislo.kr"} + + +@pytest.mark.parametrize( + "email,expected", + [ + ("a@forest.go.kr", True), + ("A@KFS.GO.KR", True), + ("a@nifos.sub.forest.go.kr", True), + ("a@gmail.com", False), + ("a@notgo.kr", False), + ("a@go.kr.evil.com", False), + (None, False), + ], +) +def test_forest_email(email, expected): + assert auth.is_forest_email(email) is expected + + +def test_forest_service(): + assert auth.is_forest_service(FOREST) + assert auth.is_forest_service(ADMIN) + assert not auth.is_forest_service(USER) + + +def _plan(**kw): + body = {"project_name": "예정", "project_year": 2026, "region": "홍천", "road_type": "fire"} + return d01.CreatePlanRequest(**{**body, **kw}) + + +def test_d01_plan_forbidden_for_user(): + with pytest.raises(HTTPException) as e: + asyncio.run(d01.post_plan_project(None, _plan(), USER)) + assert e.value.status_code == 403 + + +def test_d01_plan_created(monkeypatch): + seen = {} + + async def fake_create(**kw): + seen.update(kw) + return "new-id" + + monkeypatch.setattr(d01, "create_kind_project", fake_create) + out = asyncio.run(d01.post_plan_project(None, _plan(start_chainage_m=0), FOREST)) + assert out == {"project_id": "new-id"} + assert seen["kind"] == "PLAN" and seen["company_id"] == 4 + assert seen["design_speed_kph"] == 20 and seen["project_year_end"] == 2026 + asyncio.run(d01.post_plan_project(None, _plan(design_speed_kph=40), ADMIN)) + assert seen["design_speed_kph"] == 40 + asyncio.run(d01.post_plan_project(None, _plan(road_type="work", design_speed_kph=40), ADMIN)) + assert seen["design_speed_kph"] == 20 # 작업임도 = 20 만 + with pytest.raises(HTTPException) as e: + asyncio.run(d01.post_plan_project(None, _plan(project_year_end=2025), FOREST)) + assert e.value.status_code == 400 + + +def _e01(monkeypatch, rows): + seen = {} + + async def fake_sources(company_id, project_id=None): + return rows + + async def fake_create(**kw): + seen.update(kw) + return "stab-id" + + monkeypatch.setattr(e01, "_sources", fake_sources) + monkeypatch.setattr(e01, "create_kind_project", fake_create) + payload = e01.CreateStabRequest(source_project_id="s" * 36) + return seen, lambda: asyncio.run(e01.post_stab_project(None, payload, USER)) + + +def test_e01_requires_b06(monkeypatch): + src = {"project_id": "s" * 36, "name": "임도", "region": "r", "road_type": "fire"} + _, call = _e01(monkeypatch, [{**src, "project_year": 2026, "section_complete": 0}]) + with pytest.raises(HTTPException) as e: + call() + assert e.value.status_code == 400 + _, call = _e01(monkeypatch, []) + with pytest.raises(HTTPException) as e: + call() + assert e.value.status_code == 404 + seen, call = _e01(monkeypatch, [{**src, "project_year": 2026, "section_complete": 1}]) + assert call() == {"project_id": "stab-id"} + assert seen["kind"] == "STABILITY" and seen["source_project_id"] == "s" * 36 + assert seen["name"] == "임도 안정성평가" + + +def _access(monkeypatch, method, assigned, owner_company=2): + pool = FakePool( + { + "FROM projects": (owner_company,), + "plan_route_assignees": (1,) if assigned else None, + } + ) + monkeypatch.setattr(auth, "get_db_pool", lambda: pool) + return asyncio.run(auth.require_project_access(Req(method), USER)) + + +def test_shared_get_only(monkeypatch): + assert _access(monkeypatch, "GET", True) is USER + for method in ("PUT", "POST", "DELETE"): + with pytest.raises(HTTPException) as e: + _access(monkeypatch, method, True) + assert e.value.status_code == 403 + with pytest.raises(HTTPException) as e: + _access(monkeypatch, "GET", False) + assert e.value.status_code == 403 + assert _access(monkeypatch, "PUT", False, owner_company=1) is USER + + +def test_register_forest_company(monkeypatch): + pool = FakePool({"FROM companies": (4,)}) + monkeypatch.setattr(auth_repo, "get_db_pool", lambda: pool) + assert asyncio.run(auth_repo.complete_registration(5, False, forest=True)) == "ACTIVE" + update = [a for s, a in pool.log if s.startswith("UPDATE users")] + assert update == [(4, 5)] + pool = FakePool({"FROM companies": (4,)}) + monkeypatch.setattr(auth_repo, "get_db_pool", lambda: pool) + assert asyncio.run(auth_repo.complete_registration(5, False)) == "NO_COMPANY" + assert not any("companies" in s for s, _ in pool.log) + + +def test_register_verify_forces_forest(monkeypatch): + seen = {} + + async def fake_user(email): + return {"id": 5, "email": email, "status": "PENDING_EMAIL", "is_master": 0} + + async def fake_otp(user_id, kind): + from datetime import datetime, timedelta + + return {"id": 1, "expires_at": datetime.utcnow() + timedelta(minutes=5), "otp_hash": "h"} + + async def fake_complete(user_id, is_master, forest=False): + seen["forest"] = forest + return "ACTIVE" if forest else "NO_COMPANY" + + async def noop(*a, **k): + return None + + monkeypatch.setattr(reg_router, "get_user_by_email", fake_user) + monkeypatch.setattr(reg_router, "get_active_otp", fake_otp) + monkeypatch.setattr(reg_router, "verify_password", lambda a, b: True) + monkeypatch.setattr(reg_router, "consume_otp", noop) + monkeypatch.setattr(reg_router, "trust_device", noop) + monkeypatch.setattr(reg_router, "complete_registration", fake_complete) + + class R: + headers = {} + + body = reg_router.RegisterVerifyRequest(email="Lee@KFS.go.kr", otp_code="123456") + resp = asyncio.run(reg_router.verify_registration(body, R())) + assert seen["forest"] is True and b'"ACTIVE"' in resp.body + body = reg_router.RegisterVerifyRequest(email="lee@gmail.com", otp_code="123456") + resp = asyncio.run(reg_router.verify_registration(body, R())) + assert seen["forest"] is False and b'"NO_COMPANY"' in resp.body + + +def test_register_ignores_sent_company(): + data = RegisterRequest( + email="a@kfs.go.kr", + password="12345678", + password_confirm="12345678", + name="이", + terms_version="1.0", + terms_agreed=True, + privacy_agreed=True, + company_name="다른회사", + ).model_dump() + assert "company_name" not in data + + +@pytest.mark.parametrize("kind,count", [("ROAD", 7), ("PLAN", 3), ("STABILITY", 4)]) +def test_stage_seed_by_kind(kind, count): + log = [] + asyncio.run(initialize_project_stages(FakeCursor({}, log), "p1", kind)) + assert len(log) == count + if kind == "PLAN": + assert [a[2] for _, a in log] == ["PLAN_BASE", "PLAN_ANALYSIS", "PLAN_CONFIRM"] + + +def test_storage_dirs_allowed(): + for name in ("D02_PlanBase", "D04_PlanConfirm", "E02_StabGround", "E05_StabReport"): + assert name in PROJECT_STORAGE_STAGE_DIRS