diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 75850c9cb..23ec0d270 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -166,6 +166,8 @@ export const STATE_REGISTRY = { crossdesign: { bucket: "draft", scope: "route" }, /** B08 홍수량 계산서 입력 `{측점: 입력}` — [저장] 때 B08 자기 파일로 · B05 연결값과 같은 칸은 null(연동 · 96). */ "b08-hydro": { bucket: "draft", scope: "project", version: 3 }, + /** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */ + "b08-options": { bucket: "draft", scope: "project" }, /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). * 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */ "alignment-draft": { diff --git a/B08_Quantity/B08_Quantity_Options_Calc.ts b/B08_Quantity/B08_Quantity_Options_Calc.ts new file mode 100644 index 000000000..b4f33256c --- /dev/null +++ b/B08_Quantity/B08_Quantity_Options_Calc.ts @@ -0,0 +1,108 @@ +/* ============================================================================= + * B08_Quantity_Options_Calc.ts + * 좌측 패널 옵션 — 조작 중 브라우저가 바로 다시 세는 식(화면 없음 · PLAN 122). + * + * 짝(파이썬 · 같은 식) — 거울 시험 `test_122_b08_options_mirror`: + * couplings · recountCouplings ↔ `B08_Quantity_Router_Summary.py` couplings(관마다 `pipes`) + * rockSplit · splitRockColumns · splitRockSummary + * ↔ `B08_Quantity_Engine_Earthwork_Summary.py` rock_split · + * split_rock_columns · summary_rows(연암 · 경암 네 줄) + * [저장] 뒤에는 서버가 정본으로 다시 셈 — 여기 값을 받아 적지 않음. + * import 없음 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +/** 커플링 = 올림(L ÷ 1본) − 1(0 아래 없음) · 길이나 1본이 빈칸이면 null. */ +export function couplings(lengthM: number | null | undefined, oneM: number | null): number | null { + if (!lengthM || !oneM) return null; + return Math.max(Math.ceil(lengthM / oneM - 1e-9) - 1, 0); +} + +export interface PipeLength { + chainage_m: number; + /** 커플링 열 id(`pipe:cp|관경`) */ + column: string; + length_m: number | null; +} + +interface CouplingSummary { + rows: Array<{ chainage_m: number; values: Record }>; + total: Record; + pipes: PipeLength[]; +} + +/** 커플링 열만 새 1본 길이로 — 같은 측점(1 cm)의 같은 열은 더함 · 계 = 줄 합. */ +export function recountCouplings(summary: S, oneM: number | null): S { + const at = (chainage: number): number => Math.round(chainage * 100); + const columns = new Set(summary.pipes.map((pipe) => pipe.column)); + const sums = new Map(); + for (const pipe of summary.pipes) { + const count = couplings(pipe.length_m, oneM); + if (count === null) continue; + const key = `${at(pipe.chainage_m)}|${pipe.column}`; + sums.set(key, (sums.get(key) ?? 0) + count); + } + const keep = (values: Record): Record => + Object.fromEntries(Object.entries(values).filter(([id]) => !columns.has(id))); + const rows = summary.rows.map((row) => { + const values = keep(row.values); + for (const column of columns) { + const count = sums.get(`${at(row.chainage_m)}|${column}`); + if (count !== undefined) values[column] = count; + } + return { ...row, values }; + }); + const total = keep(summary.total); + for (const row of rows) + for (const column of columns) + if (row.values[column] !== undefined) + total[column] = (total[column] ?? 0) + row.values[column]; + return { ...summary, rows, total }; +} + +/** 0~100 으로 자름 · 숫자가 아니면 null. */ +export function clampPct(value: number): number | null { + return Number.isFinite(value) ? Math.min(Math.max(value, 0), 100) : null; +} + +/** 암 → [연암, 경암] — 연암 = 암 × 비율(0~100 자름) · 경암 = 암 − 연암. */ +export function rockSplit(value: number, softPct: number): [number, number] { + const soft = (value * Math.min(Math.max(softPct, 0), 100)) / 100; + return [soft, value - soft]; +} + +const ROCK_SPLIT = [ + ["cut_rock", "cut_soft_rock", "cut_hard_rock"], + ["ditch_rock", "ditch_soft_rock", "ditch_hard_rock"], +] as const; +const ROCK_SUFFIXES = ["area_m2", "volume_m3", "adjusted_m3"] as const; + +/** 줄 · 합계의 암 칸마다 연암 · 경암 칸을 덮어씀(숫자인 칸만). */ +export function splitRockColumns(record: Record, softPct: number): void { + for (const [base, softKey, hardKey] of ROCK_SPLIT) + for (const suffix of ROCK_SUFFIXES) { + const value = record[`${base}_${suffix}`]; + if (typeof value !== "number") continue; + const [soft, hard] = rockSplit(value, softPct); + record[`${softKey}_${suffix}`] = soft; + record[`${hardKey}_${suffix}`] = hard; + } +} + +/** 토공집계표 연암 · 경암 줄 — 흙깍기 = 절토 암 · 측구 터파기 = 측구 암 합계 × 비율 · 반올림 정수. */ +const ROCK_ROWS: Record = { + 흙깍기: "cut_rock_volume_m3", + "측구 터파기": "ditch_rock_volume_m3", +}; + +export function splitRockSummary( + rows: readonly R[], + totals: Record, + softPct: number, +): R[] { + return rows.map((row) => { + const base = ROCK_ROWS[row.group]; + if (!base || (row.kind !== "연암" && row.kind !== "경암")) return row; + const [soft, hard] = rockSplit(totals[base] ?? 0, softPct); + return { ...row, value: Math.round(row.kind === "연암" ? soft : hard) }; + }); +} diff --git a/B08_Quantity/B08_Quantity_Router_Options.py b/B08_Quantity/B08_Quantity_Router_Options.py new file mode 100644 index 000000000..27fbaae3b --- /dev/null +++ b/B08_Quantity/B08_Quantity_Router_Options.py @@ -0,0 +1,91 @@ +"""B08 좌측 패널 옵션 길(PLAN 122) — 배관 1본 길이 · 연암 / 경암 비율. + + GET /api/projects/{id}/quantity/options 지금 값 + PUT /api/projects/{id}/quantity/options [저장] — 받은 칸만 정본 자리에 씀 + +1본 길이 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m` 한 벌(98 `one_pipe_length` 이 읽는 자리 · +M02 표 식 `[$파형강관_1본_m]` 도 같은 값) · 프로젝트 사본이 없으면 시스템 층 문서를 바탕으로 깔고 씀 · +M02 [초기화]면 같이 되돌아감. +연암 % = `project_settings.json › quantity › rock_ratios_pct`(연암 · 경암 = 100 − 연암) · 기본 90 은 +파일에 안 쓰고 읽을 때 채움(`soft_rock_pct`). +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from B08_Quantity.B08_Quantity_Router_Hydro import _project_root +from B08_Quantity.B08_Quantity_Router_Summary import ONE_LENGTH_VAR, TABLE_NAME, one_pipe_length +from common_util.common_util_project_settings import quantity_settings, save_section, soft_rock_pct +from M02_MasterTemplete import M02_Template_Layers as layers + +router = APIRouter(prefix="/api/projects", tags=["B08 Options"]) + + +class OptionsSave(BaseModel): + """빈칸(None) = 그 칸은 그대로.""" + + one_pipe_length_m: float | None = Field(default=None, gt=0, le=100) + soft_rock_pct: float | None = Field(default=None, ge=0, le=100) + + +def read_options(root: str | Path) -> dict[str, Any]: + soft = soft_rock_pct(quantity_settings(root)) + return { + "one_pipe_length_m": one_pipe_length(root), + "rock_ratios_pct": {"연암": soft, "경암": 100.0 - soft}, + } + + +def _write_one_pipe(root: str | Path, value: float) -> None: + """프로젝트 층 구조물집계표 변수 한 칸 — 판이 그새 바뀌었으면 `StaleTemplate`.""" + folder = layers.project_dir(root) + found = layers.read_template(folder, "table", TABLE_NAME) + version = found["판"] if found else None + found = found or layers.read_template(layers.system_dir(), "table", TABLE_NAME) + if found is None: + raise HTTPException(status_code=404, detail=f"{TABLE_NAME} 양식이 없습니다 — M02 표부터.") + document = found["문서"] + document["변수"] = {**(document.get("변수") or {}), ONE_LENGTH_VAR: value} + layers.write_template( + folder, "table", TABLE_NAME, document, version=version, check_version=True + ) + + +def save_options(root: str | Path, body: OptionsSave) -> dict[str, Any]: + if body.one_pipe_length_m is not None: + _write_one_pipe(root, body.one_pipe_length_m) + if body.soft_rock_pct is not None: + soft = float(body.soft_rock_pct) + ratios = { + **(quantity_settings(root).get("rock_ratios_pct") or {}), + "연암": soft, + "경암": 100.0 - soft, + } + save_section( + root, "quantity", {"rock_ratios_pct": ratios}, replace_keys=("rock_ratios_pct",) + ) + return read_options(root) + + +@router.get("/{project_id}/quantity/options") +async def options_read(project_id: UUID) -> dict[str, Any]: + root = await _project_root(project_id) + return await asyncio.to_thread(read_options, root) + + +@router.put("/{project_id}/quantity/options") +async def options_save(project_id: UUID, body: OptionsSave) -> dict[str, Any]: + root = await _project_root(project_id) + try: + return await asyncio.to_thread(save_options, root, body) + except layers.StaleTemplate as error: + raise HTTPException( + status_code=409, detail="구조물집계표가 그새 바뀌었습니다 — 다시 열고 저장하세요." + ) from error diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.ts b/B08_Quantity/B08_Quantity_UI_Hydro.ts index 07450ae0c..5056c993a 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro.ts @@ -44,7 +44,7 @@ interface HydroStation { type Drafts = Record>; type HydroPayload = { stations: HydroStation[]; saved: Drafts } & HydroOptions; -async function request(path: string, init: RequestInit = {}): Promise { +export async function request(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", headers: { "Content-Type": "application/json" }, diff --git a/B08_Quantity/B08_Quantity_UI_Options.ts b/B08_Quantity/B08_Quantity_UI_Options.ts new file mode 100644 index 000000000..d994e765d --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Options.ts @@ -0,0 +1,201 @@ +/* ============================================================================= + * B08_Quantity_UI_Options.ts + * 좌측 패널(PLAN 122) — 메인 탭과 같은 컨테이너 셋(구조물 · 토공 · 수리집수면적표 · 접이) + [저장] 줄. + * + * 구조물 › 배관 1본 길이(m) — 정본 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m` + * 토공 › 연암 % · 경암 %(암 전체 기준 · 한쪽 고치면 다른 쪽 = 100 − 값 · 0~100) + * 수리집수면적표 — 빈 자리(다음 옵션) + * 조작 = 캐시 `b08-options` 즉시 + 열린 표가 같은 식으로 바로 다시 셈(`…_Options_Calc`) · + * [저장] = PUT …/quantity/options → 캐시 비움 · 열린 탭을 서버 정본으로 다시 그림 · 자동 저장 없음. + * ========================================================================== */ + +import { + createButton, + createInputField, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { request } from "./B08_Quantity_UI_Hydro"; +import { clampPct } from "./B08_Quantity_Options_Calc"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 조작 중 값 — 없는 칸은 저장본 그대로 · 1본 null = 빈칸. */ +export interface OptionsDraft { + one_pipe_length_m?: number | null; + soft_rock_pct?: number; +} + +interface SavedOptions { + one_pipe_length_m: number | null; + rock_ratios_pct: { 연암: number; 경암: number }; +} + +const changes = new EventTarget(); + +const readDraft = (projectId: string): OptionsDraft => + readState("b08-options", projectId) ?? {}; + +/** 지금 1본 길이 — 캐시 → 저장본. */ +export function liveOnePipe(projectId: string, saved: number | null): number | null { + const draft = readDraft(projectId); + return draft.one_pipe_length_m !== undefined ? draft.one_pipe_length_m : saved; +} + +/** 지금 연암 % — 캐시 → 저장본. */ +export function liveSoftRock(projectId: string, saved: number): number { + return readDraft(projectId).soft_rock_pct ?? saved; +} + +/** host 가 화면에 붙어 있는 동안 옵션이 바뀔 때마다 fn — 떨어진 뒤 첫 알림에서 스스로 뗌. */ +export function onOptionsChange(host: HTMLElement, fn: () => void): void { + const listener = (): void => { + if (host.isConnected) fn(); + else changes.removeEventListener("change", listener); + }; + changes.addEventListener("change", listener); +} + +const pctText = (value: number): string => String(Math.round(value * 1e6) / 1e6); + +function card(id: string, title: keyof typeof ui_locales, ...body: HTMLElement[]): HTMLElement { + const section = document.createElement("section"); + section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card"; + section.dataset.b08Options = id; + const heading = document.createElement("h3"); + heading.className = "ui-collapsible__title"; + heading.textContent = L(title); + section.append(heading, ...body); + return section; +} + +function hint(text: string): HTMLElement { + const p = document.createElement("p"); + p.className = "b08-options__hint"; + p.textContent = text; + return p; +} + +/** 좌측 패널 — 맨 끝 `.ui-sidebar-actions` 줄([저장] · [?] 자리). `onSaved` = 열린 탭 다시 그림. */ +export function createOptionsPanel(projectId: string | null, onSaved: () => void): HTMLElement { + const one = createInputField({ + label: "배관 1본 길이 (m)", + type: "number", + compact: true, + min: 0, + }); + const soft = createInputField({ + label: "연암 (%)", + type: "number", + compact: true, + min: 0, + max: 100, + }); + const hard = createInputField({ + label: "경암 (%)", + type: "number", + compact: true, + min: 0, + max: 100, + }); + one.input.dataset.b08Option = "one_pipe_length_m"; + soft.input.dataset.b08Option = "soft_rock_pct"; + hard.input.dataset.b08Option = "hard_rock_pct"; + const rockRow = document.createElement("div"); + rockRow.className = "b08-options__row"; + rockRow.append(soft.root, hard.root); + + let saved: { one: number | null; soft: number } = { one: 8, soft: 90 }; + const show = (): void => { + const draft = projectId ? readDraft(projectId) : {}; + const oneM = draft.one_pipe_length_m !== undefined ? draft.one_pipe_length_m : saved.one; + const softPct = draft.soft_rock_pct ?? saved.soft; + one.input.value = oneM == null ? "" : String(oneM); + soft.input.value = pctText(softPct); + hard.input.value = pctText(100 - softPct); + }; + const put = (patch: OptionsDraft): void => { + if (!projectId) return; + writeState("b08-options", { ...readDraft(projectId), ...patch }, projectId); + changes.dispatchEvent(new Event("change")); + }; + + one.input.addEventListener("input", () => { + const value = Number(one.input.value); + put({ one_pipe_length_m: one.input.value.trim() && value > 0 ? value : null }); + }); + // 한쪽을 고치면 다른 쪽 = 100 − 값 · 칸을 떠나면 자른 값으로 다시 씀 + const pair = (from: HTMLInputElement, other: HTMLInputElement, isSoft: boolean): void => { + from.addEventListener("input", () => { + const value = from.value.trim() ? clampPct(Number(from.value)) : null; + if (value === null) return; + other.value = pctText(100 - value); + put({ soft_rock_pct: isSoft ? value : 100 - value }); + }); + from.addEventListener("change", show); + }; + pair(soft.input, hard.input, true); + pair(hard.input, soft.input, false); + + const saveAll = async (): Promise => { + if (!projectId) return; + const draft = readDraft(projectId); + if (draft.one_pipe_length_m === null) { + showToast("배관 1본 길이를 넣으세요 — 0 보다 큰 값.", "error"); + return; + } + showLoadingOverlay(); + try { + const body = await request(`/projects/${projectId}/quantity/options`, { + method: "PUT", + body: JSON.stringify(draft), + }); + saved = { one: body.one_pipe_length_m, soft: body.rock_ratios_pct.연암 }; + clearState("b08-options", projectId); + show(); + showToast("수량 산출 옵션을 저장했습니다.", "success"); + onSaved(); + } catch (error) { + showToast(`저장하지 못했습니다 — ${String(error)}`, "error"); + } finally { + hideLoadingOverlay(); + } + }; + + const save = createButton({ + label: L("B08_Quantity_Btn_Save"), + variant: "filled", + onClick: () => void saveAll(), + }); + save.dataset.b08OptionsSave = ""; + const actions = document.createElement("div"); + actions.className = "ui-sidebar-actions"; + actions.append(save); + + const panel = document.createElement("div"); + panel.className = "ui-sidebar-panel b08-options"; + panel.append( + card("structure", "B08_Quantity_Tab_Structure", one.root), + card("earthwork", "B08_Quantity_Tab_Earthwork", rockRow, hint("암 전체 기준 · 두 칸 합 100")), + card("hydrology", "B08_Quantity_Tab_Hydrology", hint("다음 옵션 자리")), + actions, + ); + attachCollapsible(panel); + show(); + + if (projectId) + request(`/projects/${projectId}/quantity/options`).then( + (body) => { + saved = { one: body.one_pipe_length_m, soft: body.rock_ratios_pct.연암 }; + show(); + }, + (error: unknown) => showToast(`옵션을 불러오지 못했습니다 — ${String(error)}`, "error"), + ); + return panel; +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index a74e7d678..332249424 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -18,3 +18,16 @@ padding-bottom: var(--spacing-8); border-bottom: 1px solid var(--color-border); } + +/* 좌측 패널 옵션(122) — 연암 · 경암 두 칸 나란히 · 설명 한 줄 */ +.b08-options__row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-8); +} + +.b08-options__hint { + margin: var(--spacing-4) 0 0; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index c2621bab7..6982d8c84 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -6,6 +6,7 @@ * 구조물 아래 보조 탭 = 구조물 집계표(61-1) · 총괄자재수량집계표 · 콘크리트소요자재집계표 · * 자재수량집계표(셋은 아직 빈 탭) — 구조물도는 집계표 행을 눌러 여는 모달(95-3). * 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀. + * 좌측 패널 = 메인 탭과 같은 컨테이너 셋 + [저장] 줄(`B08_Quantity_UI_Options` · PLAN 122). * ========================================================================== */ import "@ui/ui_template_general_layout.css"; @@ -19,6 +20,7 @@ import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflo import { renderStructureSummary } from "./B08_Quantity_UI_Summary"; import { renderHydro } from "./B08_Quantity_UI_Hydro"; import { renderEarthwork } from "./B08_Quantity_UI_Earthwork"; +import { createOptionsPanel } from "./B08_Quantity_UI_Options"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -99,7 +101,9 @@ export function renderB08Quantity(root: HTMLElement): void { panel.className = "b08-quantity__panel"; panel.setAttribute("role", "tabpanel"); + let current: TabId = "structure"; const select = (tab: TabId): void => { + current = tab; tabBar.replaceChildren( ...TABS.map(({ id, label }) => { const button = createButton({ @@ -126,6 +130,8 @@ export function renderB08Quantity(root: HTMLElement): void { steps: workflowSteps(), activeStep: 5, mainContent: body, + // [저장] 뒤 열린 탭을 서버 정본으로 다시 그림 + leftPanel: createOptionsPanel(projectId, () => select(current)), routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex: number) => { if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 68c8fcc21..66157aecb 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -253,6 +253,21 @@ def quantity_settings(project_root: str | Path) -> dict[str, Any]: return load_settings(project_root).get("quantity") or {} +#: 연암 % 기본 — 암 전체 기준 · 경암 = 100 − 연암 · 파일에 안 쓰고 읽을 때 채움(PLAN 122) +SOFT_ROCK_PCT_DEFAULT = 90.0 + + +def soft_rock_pct(settings: dict[str, Any]) -> float: + """연암 %(0~100) — `rock_ratios_pct` 연암 · 없으면 100 − 경암 · 둘 다 없으면 기본 90.""" + ratios = settings.get("rock_ratios_pct") or {} + for name, flip in (("연암", False), ("경암", True)): + value = ratios.get(name) + if isinstance(value, (int, float)) and not isinstance(value, bool): + pct = min(max(float(value), 0.0), 100.0) + return 100.0 - pct if flip else pct + return SOFT_ROCK_PCT_DEFAULT + + def estimation_settings(project_root: str | Path) -> dict[str, Any]: """B09 구획만 꺼낸다 — 범위 계수·장비 규격처럼 **사용자가 고른 단가 조건**이 여기 산다.""" return load_settings(project_root).get("estimation") or {} diff --git a/main.py b/main.py index 1205b1eb6..31e5d193d 100644 --- a/main.py +++ b/main.py @@ -65,6 +65,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_r from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router from B08_Quantity.B08_Quantity_Router_Hydro import router as b08_hydro_router +from B08_Quantity.B08_Quantity_Router_Options import router as b08_options_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 @@ -649,6 +650,7 @@ 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) +app.include_router(b08_options_router, dependencies=protected_with_company) # B08 수량·B09 원가계산 라우터 13 개는 끊음 — 코드는 `old_code/` 에 있고 화면은 빈 틀만 뜬다(PLAN 7-3). # 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다. system_admin_only = [Depends(verify_session), Depends(require_system_admin)]