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

This commit is contained in:
2026-09-06 20:04:33 +09:00
35 changed files with 1004 additions and 334 deletions
+7
View File
@@ -251,6 +251,13 @@ const SHELL_CSS = `
align-items: center; align-items: center;
gap: var(--spacing-8); gap: var(--spacing-8);
} }
/* 사용자명·로그아웃(또는 로그인·회원가입) 버튼이 서로 붙어 보이던 것을 띄운다
(2026-09-06 사용자 지적) — 이 칸은 나중에 채워지므로 자체 간격이 필요하다. */
.app-actions__auth {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.app-outlet { .app-outlet {
min-height: calc(100vh - 64px - 56px); min-height: calc(100vh - 64px - 56px);
} }
+3 -1
View File
@@ -63,7 +63,6 @@ export interface ProjectItem {
member_user_ids?: number[]; member_user_ids?: number[];
owner_name?: string | null; owner_name?: string | null;
workflow_stage: number; workflow_stage: number;
progress_percent: number;
workflow_state?: WorkflowState; workflow_state?: WorkflowState;
updated_at?: string | null; updated_at?: string | null;
} }
@@ -118,6 +117,9 @@ export interface AuditLog {
action: string; action: string;
resource_type?: string | null; resource_type?: string | null;
resource_id?: number | null; resource_id?: number | null;
/** 대상 식별자 문자열 — 프로젝트는 UUID 라 숫자 칸에 못 담는다 (2026-09-06). */
resource_ref?: string | null;
ip_address?: string | null;
timestamp: string; timestamp: string;
} }
+30 -16
View File
@@ -10,6 +10,7 @@ from typing import Any
import aiomysql import aiomysql
import psutil import psutil
from common_util.common_util_audit import record_audit
from config.config_db import get_db_pool from config.config_db import get_db_pool
from config.config_system import ADMIN_EMAIL, EMAIL_REVERIFY_DAYS from config.config_system import ADMIN_EMAIL, EMAIL_REVERIFY_DAYS
@@ -42,10 +43,16 @@ async def role_for_company(company_id: int | None) -> str:
return "USER" return "USER"
def _stage_from_status(status: str | None) -> tuple[int, int]: def _stage_from_status(status: str | None) -> int:
"""프로젝트 상태 문자열에서 워크플로 단계만 뽑는다.
진행도(%)는 내지 않는다 (2026-09-06 사용자 지시) — 화면은 워크플로 배지로 보여 주고,
배지는 `project_workflow_stages` 표를 근거로 삼는다. 상태 문자열로 따로 세면 근거가
둘이 되어 배지와 숫자가 어긋났다.
"""
value = status or "NEW" value = status or "NEW"
if value in {"WF1_ANALYZING", "WF1_FAILED"}: if value in {"WF1_ANALYZING", "WF1_FAILED"}:
return 1, round(1 / 7 * 100) return 1
order = [ order = [
("FILE_UPLOADED", 1), ("FILE_UPLOADED", 1),
("WF1_COMPLETE", 2), ("WF1_COMPLETE", 2),
@@ -61,12 +68,11 @@ def _stage_from_status(status: str | None) -> tuple[int, int]:
for token, idx in order: for token, idx in order:
if token in value: if token in value:
stage = max(stage, idx) stage = max(stage, idx)
return stage, round(stage / 7 * 100) return stage
def _project_row(row: dict[str, Any]) -> dict[str, Any]: def _project_row(row: dict[str, Any]) -> dict[str, Any]:
stage, progress = _stage_from_status(row.get("status")) return {**row, "workflow_stage": _stage_from_status(row.get("status"))}
return {**row, "workflow_stage": stage, "progress_percent": progress}
async def _project_rows(cursor: Any, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: async def _project_rows(cursor: Any, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -251,7 +257,9 @@ async def get_project(project_id: str) -> dict[str, Any] | None:
return await cursor.fetchone() return await cursor.fetchone()
async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -> bool: async def update_project(
project_id: str, data: dict[str, Any], actor_id: int, request: Any | None = None
) -> bool:
pool = get_db_pool() pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor: async with pool.acquire() as connection, connection.cursor() as cursor:
await connection.begin() await connection.begin()
@@ -295,16 +303,19 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -
) )
changed = await cursor.fetchone() is not None changed = await cursor.fetchone() is not None
else: else:
await cursor.execute( await record_audit(
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) cursor,
VALUES (%s, 'PROJECT_UPDATE', 'project', NULL)""", actor_id=actor_id,
(actor_id,), action="PROJECT_UPDATE",
resource_type="project",
resource_ref=project_id,
request=request,
) )
await connection.commit() await connection.commit()
return changed return changed
async def soft_delete_project(project_id: str, actor_id: int) -> bool: async def soft_delete_project(project_id: str, actor_id: int, request: Any | None = None) -> bool:
pool = get_db_pool() pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor: async with pool.acquire() as connection, connection.cursor() as cursor:
await connection.begin() await connection.begin()
@@ -315,10 +326,13 @@ async def soft_delete_project(project_id: str, actor_id: int) -> bool:
) )
changed = cursor.rowcount > 0 changed = cursor.rowcount > 0
if changed: if changed:
await cursor.execute( await record_audit(
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) cursor,
VALUES (%s, 'PROJECT_DELETE', 'project', NULL)""", actor_id=actor_id,
(actor_id,), action="PROJECT_DELETE",
resource_type="project",
resource_ref=project_id,
request=request,
) )
await connection.commit() await connection.commit()
return changed return changed
@@ -442,7 +456,7 @@ async def list_audit_logs(limit: int, offset: int) -> dict[str, Any]:
total = (await cursor.fetchone())["total"] total = (await cursor.fetchone())["total"]
await cursor.execute( await cursor.execute(
"""SELECT l.id, l.user_id, u.email, l.action, l.resource_type, """SELECT l.id, l.user_id, u.email, l.action, l.resource_type,
l.resource_id, l.timestamp l.resource_id, l.resource_ref, l.ip_address, l.timestamp
FROM system_audit_logs l LEFT JOIN users u ON u.id = l.user_id FROM system_audit_logs l LEFT JOIN users u ON u.id = l.user_id
ORDER BY l.timestamp DESC LIMIT %s OFFSET %s""", ORDER BY l.timestamp DESC LIMIT %s OFFSET %s""",
(limit, offset), (limit, offset),
@@ -10,6 +10,7 @@ from typing import Any
import aiomysql import aiomysql
from common_util.common_util_audit import record_audit
from config.config_db import get_db_pool from config.config_db import get_db_pool
from .B01_Dashboard_Repository import role_for_company from .B01_Dashboard_Repository import role_for_company
@@ -77,7 +78,9 @@ async def search_companies(query: str) -> list[dict[str, Any]]:
return list(await cursor.fetchall()) return list(await cursor.fetchall())
async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]: async def create_company(
user_id: int, data: dict[str, Any], request: Any | None = None
) -> dict[str, Any]:
pool = get_db_pool() pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor: async with pool.acquire() as connection, connection.cursor() as cursor:
try: try:
@@ -104,10 +107,13 @@ async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]:
WHERE id = %s""", WHERE id = %s""",
(company_id, user_id), (company_id, user_id),
) )
await cursor.execute( await record_audit(
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) cursor,
VALUES (%s, 'COMPANY_CREATE', 'company', %s)""", actor_id=user_id,
(user_id, company_id), action="COMPANY_CREATE",
resource_type="company",
resource_ref=company_id,
request=request,
) )
await connection.commit() await connection.commit()
return {"company_id": company_id, "status": "ACTIVE"} return {"company_id": company_id, "status": "ACTIVE"}
@@ -116,7 +122,9 @@ async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]:
raise raise
async def create_system_company(actor_id: int, data: dict[str, Any]) -> dict[str, Any]: async def create_system_company(
actor_id: int, data: dict[str, Any], request: Any | None = None
) -> dict[str, Any]:
pool = get_db_pool() pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor: async with pool.acquire() as connection, connection.cursor() as cursor:
try: try:
@@ -135,10 +143,13 @@ async def create_system_company(actor_id: int, data: dict[str, Any]) -> dict[str
), ),
) )
company_id = cursor.lastrowid company_id = cursor.lastrowid
await cursor.execute( await record_audit(
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) cursor,
VALUES (%s, 'COMPANY_CREATE', 'company', %s)""", actor_id=actor_id,
(actor_id, company_id), action="COMPANY_CREATE",
resource_type="company",
resource_ref=company_id,
request=request,
) )
await connection.commit() await connection.commit()
return {"company_id": company_id, "status": "ACTIVE"} return {"company_id": company_id, "status": "ACTIVE"}
+9 -4
View File
@@ -13,6 +13,7 @@ from fastapi import (
HTTPException, HTTPException,
Path, Path,
Query, Query,
Request,
Response, Response,
UploadFile, UploadFile,
) )
@@ -196,10 +197,11 @@ async def user_company_search(
@router.post("/user/company/create") @router.post("/user/company/create")
async def user_company_create( async def user_company_create(
request: Request,
payload: CreateCompanyRequest, payload: CreateCompanyRequest,
session: dict[str, Any] = Depends(verify_session), session: dict[str, Any] = Depends(verify_session),
): ):
result = await create_company(int(session["user_id"]), payload.model_dump()) result = await create_company(int(session["user_id"]), payload.model_dump(), request)
return {"status": "success", **result} return {"status": "success", **result}
@@ -369,6 +371,7 @@ async def admin_projects(session: dict[str, Any] = Depends(require_company_admin
@router.put("/projects/{project_id}") @router.put("/projects/{project_id}")
async def dashboard_update_project( async def dashboard_update_project(
project_id: str, project_id: str,
request: Request,
payload: UpdateProjectRequest, payload: UpdateProjectRequest,
session: dict[str, Any] = Depends(verify_session), session: dict[str, Any] = Depends(verify_session),
): ):
@@ -387,7 +390,7 @@ async def dashboard_update_project(
) )
await check_project_refs(int(project["company_id"]), data) await check_project_refs(int(project["company_id"]), data)
member_ids = data.pop("member_user_ids", None) member_ids = data.pop("member_user_ids", None)
if not await update_project(project_id, data, int(session["user_id"])): if not await update_project(project_id, data, int(session["user_id"]), request):
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
if member_ids is not None: if member_ids is not None:
# 만든 사람은 늘 참여자로 남는다. # 만든 사람은 늘 참여자로 남는다.
@@ -398,6 +401,7 @@ async def dashboard_update_project(
@router.delete("/projects/{project_id}") @router.delete("/projects/{project_id}")
async def dashboard_delete_project( async def dashboard_delete_project(
project_id: str, project_id: str,
request: Request,
session: dict[str, Any] = Depends(verify_session), session: dict[str, Any] = Depends(verify_session),
): ):
project = await get_project(project_id) project = await get_project(project_id)
@@ -413,7 +417,7 @@ async def dashboard_delete_project(
# 개발 PC에서만 하드 삭제. 배포 기본값은 지금까지처럼 소프트 삭제다. # 개발 PC에서만 하드 삭제. 배포 기본값은 지금까지처럼 소프트 삭제다.
delete_project = hard_delete_project if PROJECT_DELETE_HARD_ENABLED else soft_delete_project delete_project = hard_delete_project if PROJECT_DELETE_HARD_ENABLED else soft_delete_project
if not await delete_project(project_id, int(session["user_id"])): if not await delete_project(project_id, int(session["user_id"]), request):
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
return {"status": "success"} return {"status": "success"}
@@ -426,10 +430,11 @@ async def system_companies(session: dict[str, Any] = Depends(require_system_admi
@router.post("/admin/companies") @router.post("/admin/companies")
async def system_create_company( async def system_create_company(
request: Request,
payload: CreateCompanyRequest, payload: CreateCompanyRequest,
session: dict[str, Any] = Depends(require_system_admin), session: dict[str, Any] = Depends(require_system_admin),
): ):
result = await create_company(int(session["user_id"]), payload.model_dump()) result = await create_company(int(session["user_id"]), payload.model_dump(), request)
return {"status": "success", **result} return {"status": "success", **result}
+40 -4
View File
@@ -7,7 +7,13 @@ import {
openEditUserModal, openEditUserModal,
} from "./B01_Dashboard_UI_Modals"; } from "./B01_Dashboard_UI_Modals";
import { table, text } from "@ui/ui_template_general_blocks"; import { table, text } from "@ui/ui_template_general_blocks";
import { DASHBOARD_VISIBLE_ROWS, formatDate, L } from "./B01_Dashboard_UI_Common"; import {
DASHBOARD_VISIBLE_ROWS,
formatDate,
formatTime,
L,
stackedCell,
} from "./B01_Dashboard_UI_Common";
export function userTable(users: DashboardUser[], currentUser: DashboardUser): HTMLElement { export function userTable(users: DashboardUser[], currentUser: DashboardUser): HTMLElement {
return table( return table(
@@ -69,14 +75,44 @@ export function userTable(users: DashboardUser[], currentUser: DashboardUser): H
); );
} }
/** 무엇에 한 일인지 — 표에 저장된 대상 종류·번호를 사람이 읽는 말로. */
function auditTarget(log: AuditLog): string {
// 저장값은 소문자(`project`)로 들어온다 — 대문자로 맞춰 찾는다.
const key = (log.resource_type ?? "").toUpperCase();
const kind = TARGET_LABELS[key] ?? log.resource_type ?? "";
const reference = log.resource_ref ?? (log.resource_id ? String(log.resource_id) : "");
if (!kind) return reference || "-";
// 프로젝트 UUID 는 길어 앞 8자만 — 어느 프로젝트인지 가리기에는 충분하다.
const shortened = reference.length > 12 ? `${reference.slice(0, 8)}` : reference;
return shortened ? `${kind} ${shortened}` : kind;
}
const TARGET_LABELS: Record<string, string> = {
PROJECT: "프로젝트",
COMPANY: "회사",
USER: "사용자",
ASSET: "자산",
};
export function auditLogTable(logs: AuditLog[]): HTMLElement { export function auditLogTable(logs: AuditLog[]): HTMLElement {
return table( return table(
[ [
L("B01_Dashboard_Table_Email"), L("B01_Dashboard_Table_Email"),
L("B01_Dashboard_Table_Action"), // 관리 버튼 열과 같은 말(「관리」)을 돌려 쓰던 것을 갈랐다 (2026-09-06 사용자 지적).
L("B01_Dashboard_Table_Updated"), L("B01_Dashboard_Table_Event"),
L("B01_Dashboard_Table_Target"),
L("B01_Dashboard_Table_Origin"),
L("B01_Dashboard_Table_When"),
], ],
logs.map((log) => [text(log.email), text(log.action), text(formatDate(log.timestamp))]), logs.map((log) => [
text(log.email),
text(log.action),
text(auditTarget(log)),
// 접속 주소 — 기록이 없는 옛 줄은 빈칸으로 남는다 (2026-09-06부터 기록).
text(log.ip_address ?? "-"),
// 날짜와 시각을 두 줄로 — 아랫줄이 작은 글씨라 행 높이는 그대로다.
stackedCell(formatDate(log.timestamp), formatTime(log.timestamp)),
]),
DASHBOARD_VISIBLE_ROWS, DASHBOARD_VISIBLE_ROWS,
); );
} }
+38 -8
View File
@@ -40,6 +40,32 @@ export function formatDate(value?: string | null): string {
return value ? value.slice(0, 10) : "-"; return value ? value.slice(0, 10) : "-";
} }
/** 시:분 — 표에서 날짜 아래 줄에 붙인다 (2026-09-06 사용자 지시). */
export function formatTime(value?: string | null): string {
if (!value) return "";
const time = value.includes("T") ? value.split("T")[1] : value.slice(11);
return time ? time.slice(0, 5) : "";
}
/**
* 위·아래 두 줄짜리 표 칸. 아랫줄은 작은 글씨라 **행 높이는 한 줄일 때와 같다**.
* 날짜/시각처럼 한 칸에 두 값을 넣을 때 쓴다.
*/
export function stackedCell(top: string, bottom: string): HTMLElement {
const cell = document.createElement("div");
cell.className = "b01-dashboard__stacked";
const first = document.createElement("span");
first.textContent = top;
cell.append(first);
if (bottom) {
const second = document.createElement("span");
second.className = "b01-dashboard__stacked-sub";
second.textContent = bottom;
cell.append(second);
}
return cell;
}
/* ----------------------------------------------------------------------------- /* -----------------------------------------------------------------------------
* 모달 바깥 클릭으로 닫기 (2026-09-04 사용자 지시) * 모달 바깥 클릭으로 닫기 (2026-09-04 사용자 지시)
* *
@@ -149,13 +175,17 @@ export function attachModalDismiss(
* (2026-09-06 사용자 지시, 템플릿 일원화). 순서는 이름 > 직급 > 이메일 > 부서 > 전화이며 * (2026-09-06 사용자 지시, 템플릿 일원화). 순서는 이름 > 직급 > 이메일 > 부서 > 전화이며
* 이메일은 계정 식별자라 읽기 전용이다. * 이메일은 계정 식별자라 읽기 전용이다.
*/ */
export function buildUserFields(source: { export function buildUserFields(
name: string; source: {
email?: string; name: string;
position?: string | null; email?: string;
department?: string | null; position?: string | null;
phone?: string | null; department?: string | null;
}): { phone?: string | null;
},
/** 본인 정보 화면에서는 「팀원 이메일」이 아니라 「이메일」이다 (2026-09-06 사용자 지시). */
options: { self?: boolean } = {},
): {
grid: HTMLElement; grid: HTMLElement;
validate: () => boolean; validate: () => boolean;
values: () => { values: () => {
@@ -175,7 +205,7 @@ export function buildUserFields(source: {
value: source.position ?? "", value: source.position ?? "",
}); });
const email = createInputField({ const email = createInputField({
label: L("B01_Dashboard_Field_MemberEmail"), label: L(options.self ? "B01_Dashboard_Field_Email" : "B01_Dashboard_Field_MemberEmail"),
type: "email", type: "email",
value: source.email ?? "", value: source.email ?? "",
}); });
+15 -19
View File
@@ -7,6 +7,7 @@ import {
showToast, showToast,
} from "@ui/ui_template_elements"; } from "@ui/ui_template_elements";
import { section } from "@ui/ui_template_general_blocks"; import { section } from "@ui/ui_template_general_blocks";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { navigateTo } from "../A00_Common/router"; import { navigateTo } from "../A00_Common/router";
import { import {
fetchAllCompanies, fetchAllCompanies,
@@ -130,9 +131,8 @@ async function loadRoleData(state: DashboardState): Promise<void> {
} }
function buildPage(state: DashboardState): HTMLElement { function buildPage(state: DashboardState): HTMLElement {
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
const page = document.createElement("div"); const page = document.createElement("div");
page.className = "b01-dashboard";
page.append(buildHeader(state.user));
const grid = document.createElement("div"); const grid = document.createElement("div");
grid.className = "b01-dashboard__grid"; grid.className = "b01-dashboard__grid";
@@ -185,22 +185,18 @@ function buildPage(state: DashboardState): HTMLElement {
grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true)); grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true));
} }
page.append(grid); page.append(grid);
return page; const layout = createGeneralLayout({
} pageClass: "b01-dashboard",
title: L("B01_Dashboard_Title"),
function buildHeader(user: DashboardUser): HTMLElement { subtitle: L("B01_Dashboard_Subtitle"),
const header = document.createElement("header"); content: page,
header.className = "b01-dashboard__header"; });
const text = document.createElement("div"); // 역할 배지는 제목 줄 오른쪽에 둔다(자리는 CSS 격자가 잡는다).
const title = document.createElement("h1"); const tag = createTag(
title.className = "b01-dashboard__title"; roleLabel(state.user.role),
title.textContent = L("B01_Dashboard_Title"); state.user.role === "SYSTEM_ADMIN" ? "accent" : "neutral",
const subtitle = document.createElement("p"); );
subtitle.className = "b01-dashboard__subtitle";
subtitle.textContent = L("B01_Dashboard_Subtitle");
text.append(title, subtitle);
const tag = createTag(roleLabel(user.role), user.role === "SYSTEM_ADMIN" ? "accent" : "neutral");
tag.classList.add("b01-dashboard__role"); tag.classList.add("b01-dashboard__role");
header.append(text, tag); layout.root.querySelector(".ui-general-layout__header")?.append(tag);
return header; return layout.root;
} }
+30 -3
View File
@@ -1,13 +1,40 @@
import { isBlank } from "@util/common_util_validate"; import { isBlank } from "@util/common_util_validate";
import { createButton, createInputField } from "@ui/ui_template_elements"; import { createButton, createInputField } from "@ui/ui_template_elements";
import { changePassword, updateUserProfile, type DashboardUser } from "./B01_Dashboard_Api_Fetch"; import {
changePassword,
fetchCompanyAssets,
updateCompanyAsset,
updateUserProfile,
type DashboardUser,
} from "./B01_Dashboard_Api_Fetch";
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
import { buildUserFields, L, runRequest } from "./B01_Dashboard_UI_Common"; import { buildUserFields, L, runRequest } from "./B01_Dashboard_UI_Common";
const PASSWORD_MIN_LENGTH = 8; const PASSWORD_MIN_LENGTH = 8;
export function buildProfileForm(user: DashboardUser): HTMLElement { export function buildProfileForm(user: DashboardUser): HTMLElement {
const fields = buildUserFields(user); // 로그인한 본인의 정보다 — 이메일 라벨도 「이메일」로 나간다 (2026-09-06 사용자 지시).
const fields = buildUserFields(user, { self: true });
const grid = fields.grid; const grid = fields.grid;
// 본인 서명 — 사용자 수정 모달과 같은 칸이다. 도면 표제란이 이 사람 자리를 채울 때
// 그대로 실리므로 본인이 여기서 바로 걸 수 있게 둔다 (2026-09-06 사용자 지시).
const signatureSlot = document.createElement("div");
if (user.company_id) {
void fetchCompanyAssets(user.company_id).then((assets) => {
const owned = assets.find((asset) => asset.kind === "SIGNATURE" && asset.user_id === user.id);
signatureSlot.append(
createAssetField("서명", "SIGNATURE", assets, owned?.id ?? null, user.company_id!, user, {
owner: { id: user.id, name: user.name },
onChange: async (assetId) => {
if (assetId === null) return;
const picked = assets.find((asset) => asset.id === assetId);
if (picked)
await updateCompanyAsset(assetId, { label: picked.label, user_id: user.id });
},
}).root,
);
});
}
const save = createButton({ const save = createButton({
label: L("B01_Dashboard_SaveProfile"), label: L("B01_Dashboard_SaveProfile"),
onClick: async function onB01_Profile_Save_Click() { onClick: async function onB01_Profile_Save_Click() {
@@ -16,7 +43,7 @@ export function buildProfileForm(user: DashboardUser): HTMLElement {
}, },
}); });
const wrap = document.createElement("div"); const wrap = document.createElement("div");
wrap.append(grid, save); wrap.append(grid, signatureSlot, save);
return wrap; return wrap;
} }
+1 -2
View File
@@ -13,7 +13,7 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser
[ [
L("B01_Dashboard_Table_Project"), L("B01_Dashboard_Table_Project"),
L("B01_Dashboard_Table_Region"), L("B01_Dashboard_Table_Region"),
L("B01_Dashboard_Table_Progress"), // 진행도(%) 열은 없앴다 (2026-09-06 사용자 지시) — 워크플로 배지가 같은 것을 보여 준다.
L("B01_Dashboard_Table_Workflow"), L("B01_Dashboard_Table_Workflow"),
L("B01_Dashboard_Table_Updated"), L("B01_Dashboard_Table_Updated"),
L("B01_Dashboard_Table_Action"), L("B01_Dashboard_Table_Action"),
@@ -44,7 +44,6 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser
return [ return [
text(project.name), text(project.name),
text(project.region), text(project.region),
text(`${project.progress_percent}%`),
workflow(project), workflow(project),
text(formatDate(project.updated_at)), text(formatDate(project.updated_at)),
actCell, actCell,
+37 -20
View File
@@ -1,28 +1,19 @@
.b01-dashboard { /* 폭·제목·여백은 공용 템플릿(ui_template_general_layout)이 잡는다 (2026-09-06 사용자 지시).
max-width: var(--page-max-width); 여기서는 역할 배지를 제목 줄 오른쪽에 세우는 것만 한다. */
margin: 0 auto; .b01-dashboard .ui-general-layout__header {
padding: var(--spacing-40) var(--spacing-24) var(--spacing-64); display: grid;
grid-template-columns: 1fr auto;
align-items: end;
} }
.b01-dashboard__header { .b01-dashboard .ui-general-layout__title,
display: flex; .b01-dashboard .ui-general-layout__subtitle {
justify-content: space-between; grid-column: 1;
gap: var(--spacing-24);
align-items: flex-end;
margin-bottom: var(--spacing-32);
}
.b01-dashboard__title {
font-size: var(--text-heading);
}
.b01-dashboard__subtitle {
margin: var(--spacing-8) 0 0;
color: var(--color-text-secondary);
font-size: var(--text-body);
} }
.b01-dashboard__role { .b01-dashboard__role {
grid-column: 2;
grid-row: 1 / span 2;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -46,6 +37,32 @@
gap: var(--spacing-8); gap: var(--spacing-8);
} }
/* 표 안의 관리 버튼은 **한 줄로** 세운다 (2026-09-06 사용자 지시) — 두 줄로 접히면
행 높이가 두 배가 된다. 버튼을 작게 만들고 줄바꿈을 막되, 폭이 모자라면 표가
가로로 스크롤한다(`.b01-dashboard__table-wrap` 이 이미 그렇게 돼 있다). */
.b01-dashboard__table .b01-dashboard__actions {
flex-wrap: nowrap;
gap: var(--spacing-4);
}
.b01-dashboard__table .b01-dashboard__actions .ui-btn {
padding: var(--spacing-4) var(--spacing-8);
font-size: var(--text-caption);
white-space: nowrap;
}
/* 한 칸에 두 줄(날짜/시각) — 아랫줄이 작아 행 높이는 한 줄일 때와 같다. */
.b01-dashboard__stacked {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.b01-dashboard__stacked-sub {
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.b01-dashboard__table-wrap { .b01-dashboard__table-wrap {
overflow-x: auto; overflow-x: auto;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -9,6 +9,7 @@ from uuid import uuid4
import aiomysql import aiomysql
from common_util.common_util_audit import record_audit
from common_util.common_util_json import atomic_write_json from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import PROJECT_STORAGE_LAYOUT_V2 from common_util.common_util_storage import PROJECT_STORAGE_LAYOUT_V2
from common_util.common_util_workflow import load_project_workflow from common_util.common_util_workflow import load_project_workflow
@@ -43,6 +44,7 @@ def _initialize_project_storage(project_root: Path, project_id: str) -> None:
async def create_project( async def create_project(
*, *,
request: Any | None = None,
user_id: int, user_id: int,
company_id: int, company_id: int,
name: str, name: str,
@@ -110,10 +112,13 @@ async def create_project(
# 워크플로우 단계별 상태 초기화 시드 # 워크플로우 단계별 상태 초기화 시드
await initialize_project_stages(cursor, project_id) await initialize_project_stages(cursor, project_id)
await cursor.execute( await record_audit(
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) cursor,
VALUES (%s, 'PROJECT_CREATE', 'project', NULL)""", actor_id=user_id,
(user_id,), action="PROJECT_CREATE",
resource_type="project",
resource_ref=project_id,
request=request,
) )
_initialize_project_storage(project_root, project_id) _initialize_project_storage(project_root, project_id)
await connection.commit() await connection.commit()
+3 -1
View File
@@ -2,7 +2,7 @@
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from B01_Dashboard.B01_Dashboard_Repository_Members import check_project_refs from B01_Dashboard.B01_Dashboard_Repository_Members import check_project_refs
from common_util.common_util_auth import require_company from common_util.common_util_auth import require_company
@@ -15,6 +15,7 @@ router = APIRouter(prefix="/api/b02", tags=["B02_ProjRegister"])
@router.post("/project", response_model=CreateProjectResponse) @router.post("/project", response_model=CreateProjectResponse)
async def post_project( async def post_project(
request: Request,
payload: CreateProjectRequest, payload: CreateProjectRequest,
session: dict[str, Any] = Depends(require_company), session: dict[str, Any] = Depends(require_company),
) -> CreateProjectResponse: ) -> CreateProjectResponse:
@@ -50,6 +51,7 @@ async def post_project(
try: try:
result = await create_project( result = await create_project(
request=request,
user_id=int(session["user_id"]), user_id=int(session["user_id"]),
company_id=int(company_id), company_id=int(company_id),
name=payload.name.strip(), name=payload.name.strip(),
@@ -0,0 +1,96 @@
/* =============================================================================
* B02_ProjRegister_UI_Name.ts
* 프로젝트명 조합기 — 사업연도·사업지역·임도종류를 이름 칸에 넣어 두고, 사용자가 그 글을
* 고쳐도 위 항목과의 연결이 끊기지 않게 지킨다 (2026-09-06 사용자 지시).
*
* 글자마다 「누가 넣은 글자인가」를 같이 들고 다닌다. 문자열을 찾아 바꾸던 종전 방식은
* 사용자가 자동 글자 사이에 한 글자만 끼워 넣어도 찾기가 실패해 연동이 통째로 끊겼다
* (실측: 사업지역 「울진군 금강송면」의 「금강송면」 앞에 글자 삽입).
*
* 규칙
* - 사용자가 이름을 고치기 전에는 위 세 항목으로 통째로 다시 쓴다.
* - 고친 뒤에는 **그 항목이 차지한 구간만** 새 값으로 갈아 끼운다. 사용자가 그 구간
* 밖(사이·끝)에 넣은 글은 그대로 남는다.
* - 사용자가 지워 버린 항목은 되살리지 않는다 — 지운 것도 사용자의 뜻이다.
* ========================================================================== */
/** 이름을 이루는 자동 조각의 종류. 순서가 곧 이름에 놓이는 차례다. */
export type NameSlot = "year" | "region" | "type";
const SLOTS: readonly NameSlot[] = ["year", "region", "type"];
export type NameParts = Record<NameSlot, string>;
export interface NameComposer {
/** 지금 이름. */
text(): string;
/** 사용자가 이름 칸에 친 글을 반영한다. */
edit(next: string): void;
/** 위 항목이 바뀌었을 때 — 갈아 끼운 이름을 돌려준다. */
update(parts: NameParts): string;
}
export function createNameComposer(): NameComposer {
let text = "";
/** 글자마다 그 글자를 넣은 항목(사용자가 친 글자는 null). `text`와 길이가 같다. */
let owners: (NameSlot | null)[] = [];
let touched = false;
const fill = (slot: NameSlot | null, count: number): (NameSlot | null)[] =>
Array.from({ length: count }, () => slot);
const rebuild = (parts: NameParts): void => {
text = "";
owners = [];
for (const slot of SLOTS) {
const value = parts[slot].trim();
if (!value) continue;
if (text.length > 0) {
text += " ";
owners.push(null);
}
text += value;
owners.push(...fill(slot, value.length));
}
};
return {
text: () => text,
edit(next: string): void {
touched = true;
// 앞뒤로 그대로인 부분을 뺀 **바뀐 구간**만 계산한다 — 어디를 고쳤는지 알아야
// 나머지 글자의 주인이 밀리지 않는다.
let head = 0;
while (head < text.length && head < next.length && text[head] === next[head]) head += 1;
let tail = 0;
while (
tail < text.length - head &&
tail < next.length - head &&
text[text.length - 1 - tail] === next[next.length - 1 - tail]
) {
tail += 1;
}
const removed = text.length - tail - head;
const inserted = next.slice(head, next.length - tail);
owners.splice(head, removed, ...fill(null, inserted.length));
text = next;
},
update(parts: NameParts): string {
if (!touched) {
rebuild(parts);
return text;
}
for (const slot of SLOTS) {
const first = owners.indexOf(slot);
if (first < 0) continue; // 사용자가 지운 항목은 되살리지 않는다.
const last = owners.lastIndexOf(slot);
const value = parts[slot].trim();
text = text.slice(0, first) + value + text.slice(last + 1);
owners.splice(first, last - first + 1, ...fill(slot, value.length));
}
return text;
},
};
}
+41 -69
View File
@@ -19,14 +19,10 @@ import {
showLoadingOverlay, showLoadingOverlay,
showToast, showToast,
} from "@ui/ui_template_elements"; } from "@ui/ui_template_elements";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { isBlank } from "@util/common_util_validate"; import { isBlank } from "@util/common_util_validate";
import { import { fetchCompanyMembers, fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
fetchCompanyAssets, import { createNameComposer, type NameParts } from "./B02_ProjRegister_UI_Name";
fetchCompanyMembers,
fetchDashboardMe,
fetchUserCompany,
} from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import { createAssetField } from "../B01_Dashboard/B01_Dashboard_UI_AssetPicker";
import { navigateTo } from "../A00_Common/router"; import { navigateTo } from "../A00_Common/router";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import "./B02_ProjRegister_UI_Style.css"; import "./B02_ProjRegister_UI_Style.css";
@@ -40,29 +36,15 @@ function L(key: keyof typeof ui_locales): string {
* 페이지 진입점 * 페이지 진입점
* -------------------------------------------------------------------------- */ * -------------------------------------------------------------------------- */
export function renderB02ProjRegister(root: HTMLElement): void { export function renderB02ProjRegister(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "b02-proj";
const header = document.createElement("div");
header.className = "b02-proj__header";
const title = document.createElement("h1");
title.className = "b02-proj__title";
title.textContent = L("B02_Proj_Title");
const subtitle = document.createElement("p");
subtitle.className = "b02-proj__subtitle";
subtitle.textContent = L("B02_Proj_Subtitle");
header.append(title, subtitle);
// 입력 필드 // 입력 필드
// 프로젝트명은 사업연도 + 사업지역 + 임도종류 + 직접 입력값을 이어 붙여 만든 // 프로젝트명은 사업연도 + 사업지역 + 임도종류로 **자동으로 채워지되 고칠 수 있는 칸**이
// (2026-09-06 사용자 확정). 아래 미리보기 줄이 저장될 이름 그대로다. // (2026-09-06 사용자 지시 — 종전에는 미리보기 줄이었다). 사용자가 사이나 끝에 글을 넣어도
// 위 세 항목을 다시 바꾸면 그 조각만 갈아 끼워 손댄 글이 살아남는다.
const nameField = createInputField({ const nameField = createInputField({
label: "프로젝트명 (직접 입력 부분)", label: "프로젝트명",
placeholder: "예: 가리왕산지구", placeholder: "예: 2026 가리왕산 간선임도 1구간",
required: true, required: true,
}); });
const namePreview = document.createElement("p");
namePreview.className = "b02-proj__preview";
const regionField = createInputField({ const regionField = createInputField({
label: L("B02_Proj_Field_Region"), label: L("B02_Proj_Field_Region"),
placeholder: L("B02_Proj_Field_Region_Placeholder"), placeholder: L("B02_Proj_Field_Region_Placeholder"),
@@ -120,19 +102,12 @@ export function renderB02ProjRegister(root: HTMLElement): void {
const fieldLeadField = person("분야별책임자 (도면 표제란)"); const fieldLeadField = person("분야별책임자 (도면 표제란)");
const designerField = person("설계자 (도면 표제란)"); const designerField = person("설계자 (도면 표제란)");
const personSelects = [pmField.select, fieldLeadField.select, designerField.select]; const personSelects = [pmField.select, fieldLeadField.select, designerField.select];
// 로고 칸은 회사 자산을 받아야 세울 수 있어 자리만 먼저 잡는다. // 로고 칸은 두지 않는다 (2026-09-06 사용자 지시) — 프로젝트가 이미 회사에 매여 있어
const logoSlot = document.createElement("div"); // 도면은 회사 로고를 그대로 쓴다(표제란 조회가 `COALESCE(프로젝트, 회사)`).
let logoValue: () => number | null = () => null;
void (async () => { void (async () => {
const [me, company] = await Promise.all([ const me = await fetchDashboardMe();
fetchDashboardMe(), const members = await fetchCompanyMembers().catch(() => []);
fetchUserCompany().catch(() => null),
]);
const [members, assets] = await Promise.all([
fetchCompanyMembers().catch(() => []),
fetchCompanyAssets().catch(() => []),
]);
const memberText = (member: (typeof members)[number]) => const memberText = (member: (typeof members)[number]) =>
member.position ? `${member.name} (${member.position})` : member.name; member.position ? `${member.name} (${member.position})` : member.name;
const addOption = (select: HTMLSelectElement, value: string, text: string) => { const addOption = (select: HTMLSelectElement, value: string, text: string) => {
@@ -147,32 +122,24 @@ export function renderB02ProjRegister(root: HTMLElement): void {
// 「신규 등록…」은 없앴다 — 팀원으로 등록한 뒤 고른다. // 「신규 등록…」은 없앴다 — 팀원으로 등록한 뒤 고른다.
if (members.some((member) => member.id === me.id)) select.value = String(me.id); if (members.some((member) => member.id === me.id)) select.value = String(me.id);
} }
if (company) {
// 회사 대표 로고가 기본값 — 프로젝트마다 다른 로고를 쓰면 여기서 바꾼다.
const logo = createAssetField(
"회사 로고 (도면 표제란)",
"LOGO",
assets,
company.logo_asset_id ?? null,
company.id,
me,
);
logoValue = logo.value;
logoSlot.append(logo.root);
}
})(); })();
const roadTypeText = (): string => const roadTypeText = (): string =>
roadTypeField.select.options[roadTypeField.select.selectedIndex]?.textContent ?? ""; roadTypeField.select.options[roadTypeField.select.selectedIndex]?.textContent ?? "";
const composedName = (): string => /** 이름 조합기 — 글자마다 주인을 기억해 사용자가 사이에 글을 넣어도 연동이 안 끊긴다. */
[ const nameComposer = createNameComposer();
yearField.input.value.trim(), const nameParts = (): NameParts => ({
regionField.input.value.trim(), year: yearField.input.value.trim(),
roadTypeText(), region: regionField.input.value.trim(),
nameField.input.value.trim(), type: roadTypeText(),
] });
.filter((part) => part.length > 0) nameField.input.addEventListener("input", () => {
.join(" "); nameComposer.edit(nameField.input.value);
});
const syncName = (): void => {
nameField.input.value = nameComposer.update(nameParts());
};
const composedName = (): string => nameField.input.value.trim();
const routeLength = (): number | null => { const routeLength = (): number | null => {
const start = isBlank(routeStartField.input.value) const start = isBlank(routeStartField.input.value)
? null ? null
@@ -185,14 +152,15 @@ export function renderB02ProjRegister(root: HTMLElement): void {
return end > from ? end - from : null; return end > from ? end - from : null;
}; };
const refresh = (): void => { const refresh = (): void => {
namePreview.textContent = `저장될 이름: ${composedName() || "(입력 대기)"}`; syncName();
const length = routeLength(); const length = routeLength();
routeEndField.root.querySelector(".ui-field__label")!.textContent = routeEndField.root.querySelector(".ui-field__label")!.textContent =
length === null length === null
? "노선 종료 누가거리 (m)" ? "노선 종료 누가거리 (m)"
: `노선 종료 누가거리 (m) — 연장 ${length.toFixed(1)}m`; : `노선 종료 누가거리 (m) — 연장 ${length.toFixed(1)}m`;
}; };
for (const field of [nameField, regionField, yearField, routeStartField, routeEndField]) { // 이름 칸 자신은 여기서 제외한다 — 스스로 고치는 도중에 값을 되돌리면 안 된다.
for (const field of [regionField, yearField, routeStartField, routeEndField]) {
field.input.addEventListener("input", refresh); field.input.addEventListener("input", refresh);
} }
roadTypeField.select.addEventListener("change", refresh); roadTypeField.select.addEventListener("change", refresh);
@@ -203,7 +171,6 @@ export function renderB02ProjRegister(root: HTMLElement): void {
variant: "filled", variant: "filled",
onClick: onB02_Proj_Submit_Click, onClick: onB02_Proj_Submit_Click,
}); });
submitBtn.classList.add("b02-proj__submit");
const cancelBtn = createButton({ const cancelBtn = createButton({
label: L("Common_Btn_Cancel"), label: L("Common_Btn_Cancel"),
variant: "ghost", variant: "ghost",
@@ -284,7 +251,6 @@ export function renderB02ProjRegister(root: HTMLElement): void {
pm_user_id: idOrNull(pmField.select), pm_user_id: idOrNull(pmField.select),
field_lead_user_id: idOrNull(fieldLeadField.select), field_lead_user_id: idOrNull(fieldLeadField.select),
designer_user_id: idOrNull(designerField.select), designer_user_id: idOrNull(designerField.select),
logo_asset_id: logoValue(),
}), }),
}); });
@@ -318,22 +284,28 @@ export function renderB02ProjRegister(root: HTMLElement): void {
regionField.root, regionField.root,
roadTypeField.root, roadTypeField.root,
nameField.root, nameField.root,
namePreview,
routeStartField.root, routeStartField.root,
routeEndField.root, routeEndField.root,
memoField.root,
clientOrgField.root, clientOrgField.root,
designDateField.root, designDateField.root,
pmField.root, pmField.root,
fieldLeadField.root, fieldLeadField.root,
designerField.root, designerField.root,
logoSlot, // 비고는 맨 끝 (2026-09-06 사용자 지시).
memoField.root,
); );
// 제목·버튼 줄은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — 다른 화면과 같은
// 제목 크기·여백을 쓰고, 버튼 줄은 `ui-general-block__actions` 로 세운다.
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "b02-proj__actions"; actions.className = "ui-general-block__actions b02-proj__actions";
actions.append(cancelBtn, submitBtn); actions.append(cancelBtn, submitBtn);
const card = createCard({ body: [grid, actions], raised: true }); const card = createCard({ body: [grid, actions], raised: true });
page.append(header, card); const layout = createGeneralLayout({
root.append(page); pageClass: "b02-proj",
title: L("B02_Proj_Title"),
subtitle: L("B02_Proj_Subtitle"),
content: card,
});
root.append(layout.root);
} }
+8 -38
View File
@@ -3,30 +3,13 @@
* 프로젝트 등록 페이지 전용 스타일 (theme.css 변수만 사용) * 프로젝트 등록 페이지 전용 스타일 (theme.css 변수만 사용)
* ========================================================================== */ * ========================================================================== */
.b02-proj { /* 제목·여백·버튼 줄은 공용 템플릿(ui_template_general_layout)을 쓴다 — 여기서는
이 화면에만 필요한 것(폭·2열 그리드·셀렉트 화살표·버튼 오른쪽 정렬)만 둔다. */
/* 입력 칸 두 줄짜리 폼이라 종전처럼 720px 로 좁혀 가운데 둔다 (2026-09-06 사용자 지시 —
템플릿을 쓰되 폭은 바꾸지 않는다). */
.b02-proj .ui-general-layout__inner {
max-width: 720px; max-width: 720px;
margin: 0 auto;
padding: var(--spacing-40) var(--spacing-24);
display: flex;
flex-direction: column;
gap: var(--spacing-24);
}
.b02-proj__header {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.b02-proj__title {
font-family: var(--font-display);
font-size: var(--text-heading);
color: var(--color-plum-velvet);
}
.b02-proj__subtitle {
font-size: var(--text-body-sm);
color: var(--color-text-muted);
} }
/* 2열 필드 그리드 (좁은 화면에서는 1열) */ /* 2열 필드 그리드 (좁은 화면에서는 1열) */
@@ -53,27 +36,14 @@
padding-right: var(--spacing-32); padding-right: var(--spacing-32);
} }
.b02-proj__submit {
align-self: flex-start;
margin-top: var(--spacing-8);
}
@media (max-width: 640px) { @media (max-width: 640px) {
.b02-proj__grid { .b02-proj__grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* 조합된 프로젝트명 미리보기 — 저장될 이름 그대로 보인다 (2026-09-06). */ /* 취소·등록 버튼 줄 — 나머지는 공용 `.ui-general-block__actions` 가 맡는다. */
.b02-proj__preview {
margin: calc(-1 * var(--spacing-8)) 0 0;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
/* 취소·등록 버튼 줄 */
.b02-proj__actions { .b02-proj__actions {
display: flex;
gap: var(--spacing-8);
justify-content: flex-end; justify-content: flex-end;
margin-top: var(--spacing-16);
} }
+11 -5
View File
@@ -94,6 +94,9 @@ from B03_FileInput.B03_FileInput_Router_Helpers import (
from B03_FileInput.B03_FileInput_Router_Helpers import ( from B03_FileInput.B03_FileInput_Router_Helpers import (
_write_stage_metadata as _write_stage_metadata, _write_stage_metadata as _write_stage_metadata,
) )
from B03_FileInput.B03_FileInput_Router_Helpers import (
upload_file_types as upload_file_types,
)
from B03_FileInput.B03_FileInput_Schema import ( from B03_FileInput.B03_FileInput_Schema import (
FileUploadDescriptor, FileUploadDescriptor,
FileUploadResponse, FileUploadResponse,
@@ -156,12 +159,13 @@ async def upload_project_files(
"message": "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 올릴 수 없습니다.", "message": "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 올릴 수 없습니다.",
}, },
) )
if not las_free and las_count != 1: # 지형 파일은 도엽별로 여러 장이 올 수 있다 — 합쳐서 전처리한다(2026-09-06 사용자 확정).
if not las_free and las_count < 1:
return JSONResponse( return JSONResponse(
status_code=400, status_code=400,
content={ content={
"status": "error", "status": "error",
"message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.", "message": "LAS 또는 LAZ 파일을 1개 이상 포함해야 합니다.",
}, },
) )
# 계획노선은 shapefile 또는 CSV 한 벌이다 (2026-08-31) — 문구도 그렇게 맞춘다 # 계획노선은 shapefile 또는 CSV 한 벌이다 (2026-08-31) — 문구도 그렇게 맞춘다
@@ -175,7 +179,7 @@ async def upload_project_files(
"message": "계획노선 파일(shapefile 의 .shp 또는 .csv)을 정확히 1개 포함해야 합니다.", "message": "계획노선 파일(shapefile 의 .shp 또는 .csv)을 정확히 1개 포함해야 합니다.",
}, },
) )
request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames} request_file_types = upload_file_types(filenames)
missing_required = _missing_required_file_types(request_file_types, las_free) missing_required = _missing_required_file_types(request_file_types, las_free)
if missing_required: if missing_required:
return JSONResponse( return JSONResponse(
@@ -350,8 +354,10 @@ async def get_project_upload_overview(
) )
for row in sessions for row in sessions
], ],
required_complete=_REQUIRED_FILE_TYPES <= file_types # stage 0 을 마쳤으면 서버 필수검사를 이미 통과한 것이다 — LAS 없이 설계는
and (point_cloud_id is not None or stage0_complete), # 지형 한 벌(prj·tfw)이 아예 없으므로 여기서 다시 세면 B04 이동이 막힌다.
required_complete=stage0_complete
or (_REQUIRED_FILE_TYPES <= file_types and point_cloud_id is not None),
analysis_complete=analysis_complete, analysis_complete=analysis_complete,
) )
except Exception: except Exception:
+31 -2
View File
@@ -62,14 +62,34 @@ def _is_point_cloud_result(result: UploadedFileResult) -> bool:
return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES
def upload_file_types(filenames: list[str]) -> set[str]:
"""업로드 요청의 파일명을 **완료 검사와 같은 기준**으로 유형화한다.
확장자만 세면 노선 세트의 `.prj` 지형 PRJ로 오인돼, 요청 검사는 통과하고 저장
완료 검사에서 누락으로 갈린다(2026-09-06 실측 LAS 없이 설계가 어긋남으로 막혔다).
노선 도형(`.shp`) basename이 같은 PRJ는 화면의 카드 배정과 같은 규칙으로 `route_prj`.
"""
route_stem = next(
(Path(name).stem for name in filenames if Path(name).suffix.lower() == ".shp"), None
)
types: set[str] = set()
for name in filenames:
suffix = Path(name).suffix.lower().lstrip(".")
if suffix == "prj" and route_stem is not None and Path(name).stem == route_stem:
suffix = "route_prj"
types.add(suffix)
return types
def _missing_required_file_types(file_types: set[str], las_free: bool = False) -> list[str]: def _missing_required_file_types(file_types: set[str], las_free: bool = False) -> list[str]:
missing = sorted(_REQUIRED_FILE_TYPES - file_types) # LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 지형 한 벌(포인트클라우드·지형 PRJ·
# TFW)을 통째로 받지 않는다 — 화면도 그 카드들을 필수에서 뺀다.
missing = [] if las_free else sorted(_REQUIRED_FILE_TYPES - file_types)
if not file_types.intersection(_ROUTE_FILE_TYPES): if not file_types.intersection(_ROUTE_FILE_TYPES):
missing.append("csv/shp") missing.append("csv/shp")
# shapefile로 왔으면 형제 파일이 다 있어야 노선을 읽는다. # shapefile로 왔으면 형제 파일이 다 있어야 노선을 읽는다.
if "shp" in file_types: if "shp" in file_types:
missing.extend(sorted(_SHAPEFILE_REQUIRED_TYPES - file_types)) missing.extend(sorted(_SHAPEFILE_REQUIRED_TYPES - file_types))
# LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 LAS 필수를 면제한다.
if not las_free and not file_types.intersection(_POINT_CLOUD_FILE_TYPES): if not las_free and not file_types.intersection(_POINT_CLOUD_FILE_TYPES):
missing.append("las/laz") missing.append("las/laz")
return missing return missing
@@ -143,6 +163,15 @@ async def _complete_file_input_if_ready(
# 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다. # 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다.
stored_path = await get_project_storage_relative_path(connection, project_id) stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path)) project_root = Path(resolve_stored_project_path(stored_path))
# 지형 파일 여러 장은 합쳐서 전처리한다 — 다른 사업지 파일이 섞이면 합친 범위가
# 통째로 어긋나므로 여기서 막는다(2026-09-06 사용자 지시). 머리글만 읽어 즉시 끝난다.
from B04_PreProcess.B04_PreProcess_Engine_Structurize import merge_gap_error
from B04_PreProcess.B04_PreProcess_Repository import list_project_point_cloud_paths
terrain_paths = await list_project_point_cloud_paths(connection, project_id, project_root)
gap_message = merge_gap_error(terrain_paths)
if gap_message:
raise ValueError(gap_message)
clear_designing(project_root) clear_designing(project_root)
discard_initial_snapshot(project_root) discard_initial_snapshot(project_root)
await purge_project_outputs(connection, str(project_id), project_root) await purge_project_outputs(connection, str(project_id), project_root)
+10 -3
View File
@@ -79,11 +79,18 @@ async def trigger_wf1_analysis_and_email(
await connection.commit() await connection.commit()
stored_path = await get_project_storage_relative_path(connection, project_id) stored_path = await get_project_storage_relative_path(connection, project_id)
project_info = await _get_project_notification_info(connection, project_id) project_info = await _get_project_notification_info(connection, project_id)
from B04_PreProcess.B04_PreProcess_Repository import get_input_file from B04_PreProcess.B04_PreProcess_Repository import (
get_input_file,
list_project_point_cloud_paths,
)
input_file = await get_input_file(connection, project_id, input_file_id) input_file = await get_input_file(connection, project_id, input_file_id)
project_root = Path(resolve_stored_project_path(stored_path))
# 지형 파일이 여러 장이면 합쳐서 한 벌로 전처리한다(2026-09-06 사용자 확정).
terrain_paths = await list_project_point_cloud_paths(
connection, project_id, project_root
)
project_root = Path(resolve_stored_project_path(stored_path))
source_path = project_root / Path(str(input_file["raw_file_path"])) source_path = project_root / Path(str(input_file["raw_file_path"]))
# LAS 없는 설계(2026-08-30): 입력이 계획노선 CSV면 도엽등고선 서피스 분석으로 간다. # LAS 없는 설계(2026-08-30): 입력이 계획노선 CSV면 도엽등고선 서피스 분석으로 간다.
las_free = str(input_file.get("file_type") or "").lower() not in {"las", "laz"} las_free = str(input_file.get("file_type") or "").lower() not in {"las", "laz"}
@@ -118,7 +125,7 @@ async def trigger_wf1_analysis_and_email(
analysis_result = await asyncio.to_thread( analysis_result = await asyncio.to_thread(
run_surface_analysis, run_surface_analysis,
project_root, project_root,
source_path, terrain_paths or [source_path],
source_filters=None, source_filters=None,
methods=methods, methods=methods,
force=False, force=False,
+14 -30
View File
@@ -24,15 +24,11 @@ import { createUploadFlow } from "./B03_FileInput_UI_Page_Flow";
import { import {
isSlotRequired, isSlotRequired,
slotForOverviewFile, slotForOverviewFile,
terrainCoverage,
validateFileForSlot, validateFileForSlot,
validateSlots, validateSlots,
} from "./B03_FileInput_UI_Page_Rules"; } from "./B03_FileInput_UI_Page_Rules";
import { import { readCrsLabel, renderSlotPreview } from "./B03_FileInput_UI_Preview";
readCrsLabel,
readExtent,
renderSlotPreview,
type PreviewExtent,
} from "./B03_FileInput_UI_Preview";
import { confirmReplaceUpload } from "./B03_FileInput_UI_Upload"; import { confirmReplaceUpload } from "./B03_FileInput_UI_Upload";
import { import {
createFileCardTemplate, createFileCardTemplate,
@@ -41,9 +37,11 @@ import {
initializeSlots, initializeSlots,
makeSessionKey, makeSessionKey,
planSlotAssignments, planSlotAssignments,
pushExtraFile,
ROUTE_SLOTS, ROUTE_SLOTS,
SHAPEFILE_DEPENDENT_SLOTS, SHAPEFILE_DEPENDENT_SLOTS,
slotConfigs, slotConfigs,
slotFileLabel,
TERRAIN_SLOTS, TERRAIN_SLOTS,
type FileSlot, type FileSlot,
type FileSlotState, type FileSlotState,
@@ -209,7 +207,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0; const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0;
// 로컬 파일이 없어도 서버에 업로드된 파일이 있으면 그 정보(정본)를 보여준다. // 로컬 파일이 없어도 서버에 업로드된 파일이 있으면 그 정보(정본)를 보여준다.
if (fileName) fileName.textContent = state.file?.name ?? state.serverUploaded?.name ?? ""; if (fileName) fileName.textContent = slotFileLabel(state);
if (fileSize) { if (fileSize) {
fileSize.textContent = state.file fileSize.textContent = state.file
? formatBytes(state.file.size) ? formatBytes(state.file.size)
@@ -240,7 +238,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const preview = card.querySelector<HTMLDivElement>(".b03-file__preview"); const preview = card.querySelector<HTMLDivElement>(".b03-file__preview");
if (preview) { if (preview) {
const terrain = terrainCoverage(); const terrain = terrainCoverage(slots);
renderSlotPreview(preview, { renderSlotPreview(preview, {
metadata: state.serverUploaded?.metadata, metadata: state.serverUploaded?.metadata,
// 업로드·분석이 끝난 카드에만 보인다 (2026-09-04 사용자 지시). // 업로드·분석이 끝난 카드에만 보인다 (2026-09-04 사용자 지시).
@@ -258,26 +256,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
* . * .
* (2026-09-04). * (2026-09-04).
*/ */
function terrainCoverage(): { extent: PreviewExtent | null; crs: string | null } {
let extent: PreviewExtent | null = null;
let crs: string | null = null;
for (const slot of TERRAIN_SLOTS) {
const metadata = slots.get(slot)?.serverUploaded?.metadata;
const next = readExtent(metadata);
if (!next) continue;
crs ??= readCrsLabel(metadata);
extent = extent
? {
xMin: Math.min(extent.xMin, next.xMin),
xMax: Math.max(extent.xMax, next.xMax),
yMin: Math.min(extent.yMin, next.yMin),
yMax: Math.max(extent.yMax, next.yMax),
}
: next;
}
return { extent, crs };
}
function showErrorMessage(slot: FileSlot, error: string): void { function showErrorMessage(slot: FileSlot, error: string): void {
const state = slots.get(slot); const state = slots.get(slot);
if (!state) return; if (!state) return;
@@ -297,8 +275,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
showErrorMessage(state.slot, `${validation} ${file.name}`); showErrorMessage(state.slot, `${validation} ${file.name}`);
return; return;
} }
if (!targetSlot && state.file && state.file.name !== file.name) { // 지형 자료는 도엽별로 여러 장이 온다 — 카드에 더 담고 전처리가 합쳐 쓴다.
showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`); if (state.file && state.file.name !== file.name) {
if (!pushExtraFile(state, file)) {
showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`);
return;
}
renderSlot(state.slot);
return; return;
} }
// 서버에 이미 완료된 슬롯이면 교체 확인을 받는다(2026-08-04 사용자 지시). 이어올리기로 // 서버에 이미 완료된 슬롯이면 교체 확인을 받는다(2026-08-04 사용자 지시). 이어올리기로
@@ -382,6 +365,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
localStorage.removeItem(makeSessionKey(activeProjectId, state.file)); localStorage.removeItem(makeSessionKey(activeProjectId, state.file));
} }
state.file = undefined; state.file = undefined;
state.extraFiles = undefined;
state.uploadSessionId = undefined; state.uploadSessionId = undefined;
state.uploadStatus = "pending"; state.uploadStatus = "pending";
state.progressBytes = 0; state.progressBytes = 0;
+13 -3
View File
@@ -201,14 +201,24 @@ export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle {
ctx.pageError.textContent = ""; ctx.pageError.textContent = "";
ctx.setUploading(true); ctx.setUploading(true);
try { try {
for (let index = 0; index < targetStates.length; index += 1) { // 지형 자료는 한 카드에 여러 장이 담길 수 있다 — 카드 순서대로 한 장씩 올린다.
const state = targetStates[index]; const jobs = targetStates.flatMap((state) =>
[state.file!, ...(state.extraFiles ?? [])].map((file) => ({ state, file })),
);
for (let index = 0; index < jobs.length; index += 1) {
const { state, file } = jobs[index];
if (file !== state.file) {
// 앞 파일이 쓰던 전송 세션·진행률을 물려받지 않게 되돌린다.
state.uploadSessionId = undefined;
state.progressBytes = 0;
}
await uploadOneFile( await uploadOneFile(
ctx.projectId(), ctx.projectId(),
state, state,
index === targetStates.length - 1, index === jobs.length - 1,
() => ctx.renderSlot(state.slot), () => ctx.renderSlot(state.slot),
ctx.lasFreeDesign(), ctx.lasFreeDesign(),
file,
); );
} }
clearDerivedCaches(ctx.projectId()); clearDerivedCaches(ctx.projectId());
@@ -10,6 +10,7 @@
import { UPLOAD_MAX_FILES, UPLOAD_MAX_MB } from "@config/config_frontend"; import { UPLOAD_MAX_FILES, UPLOAD_MAX_MB } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import type { UploadOverviewFile } from "./B03_FileInput_Api_Fetch"; import type { UploadOverviewFile } from "./B03_FileInput_Api_Fetch";
import { readCrsLabel, readExtent, type PreviewExtent } from "./B03_FileInput_UI_Preview";
import { import {
getExtension, getExtension,
SHAPEFILE_DEPENDENT_SLOTS, SHAPEFILE_DEPENDENT_SLOTS,
@@ -105,3 +106,31 @@ export function slotForOverviewFile(
(candidate) => candidate.slot !== "route_prj" && candidate.extensions.includes(extension), (candidate) => candidate.slot !== "route_prj" && candidate.extensions.includes(extension),
)?.slot; )?.slot;
} }
/**
* .
* (··) . 700
* (2026-09-06).
*/
export function terrainCoverage(slots: SlotMap): {
extent: PreviewExtent | null;
crs: string | null;
} {
let extent: PreviewExtent | null = null;
let crs: string | null = null;
for (const slot of TERRAIN_SLOTS) {
const metadata = slots.get(slot)?.serverUploaded?.metadata;
const next = readExtent(metadata);
if (!next) continue;
crs ??= readCrsLabel(metadata);
extent = extent
? {
xMin: Math.min(extent.xMin, next.xMin),
xMax: Math.max(extent.xMax, next.xMax),
yMin: Math.min(extent.yMin, next.yMin),
yMax: Math.max(extent.yMax, next.yMax),
}
: next;
}
return { extent, crs };
}
+25
View File
@@ -28,6 +28,12 @@ export interface SlotConfig {
export interface FileSlotState extends SlotConfig { export interface FileSlotState extends SlotConfig {
file?: File; file?: File;
/**
* () .
* ,
* (2026-09-06 ). .
*/
extraFiles?: File[];
uploadSessionId?: string; uploadSessionId?: string;
uploadStatus: UploadStatus; uploadStatus: UploadStatus;
progressBytes: number; progressBytes: number;
@@ -123,6 +129,25 @@ const SLOT_CONFIGS: readonly SlotConfig[] = [
}, },
]; ];
/** 카드에 적을 파일 이름 — 여러 장이면 「첫 장 외 N장」. */
export function slotFileLabel(state: FileSlotState): string {
const name = state.file?.name ?? state.serverUploaded?.name ?? "";
const extras = state.extraFiles?.length ?? 0;
return extras > 0 ? `${name}${extras}` : name;
}
/**
* true, false.
* () . .
*/
export function pushExtraFile(state: FileSlotState, file: File): boolean {
if (state.slot !== "las_laz") return false;
const extras = state.extraFiles ?? [];
if (!extras.some((item) => item.name === file.name)) state.extraFiles = [...extras, file];
state.error = undefined;
return true;
}
export function getExtension(fileName: string): string { export function getExtension(fileName: string): string {
const index = fileName.lastIndexOf("."); const index = fileName.lastIndexOf(".");
return index >= 0 ? fileName.slice(index).toLowerCase() : ""; return index >= 0 ? fileName.slice(index).toLowerCase() : "";
+3 -1
View File
@@ -83,8 +83,10 @@ export async function uploadOneFile(
completeUpload: boolean, completeUpload: boolean,
onProgress: () => void, onProgress: () => void,
lasFree = false, lasFree = false,
// 지형 자료는 한 카드에 여러 장이 담긴다 — 올릴 파일을 지정받는다(2026-09-06).
target?: File,
): Promise<UploadedFileResult[]> { ): Promise<UploadedFileResult[]> {
const file = state.file; const file = target ?? state.file;
if (!file) return []; if (!file) return [];
state.error = undefined; state.error = undefined;
state.uploadStatus = "uploading"; state.uploadStatus = "uploading";
+30 -14
View File
@@ -7,7 +7,7 @@
import json import json
import logging import logging
import time import time
from collections.abc import Callable from collections.abc import Callable, Sequence
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -37,14 +37,22 @@ GROUND_POINT_SAMPLE_LIMIT = 500_000
GROUND_POINT_CACHE_VERSION = 2 GROUND_POINT_CACHE_VERSION = 2
def _source_identity(las_path: Path) -> dict[str, Any]: def _source_identity(las_paths: list[Path]) -> dict[str, Any]:
"""입력 LAS의 정체성(이름·크기·수정시각)으로 캐시 세대를 식별한다 (PLAN B-2).""" """입력 지형 파일들의 정체성(이름·크기·수정시각)으로 캐시 세대를 식별한다 (PLAN B-2).
stat = las_path.stat()
return { 여러 장을 병합하므로 ** 장이라도 바뀌거나 늘고 줄면** 다시 계산해야 한다
"filename": las_path.name, (2026-09-06 다중 입력).
"size_bytes": int(stat.st_size), """
"mtime": float(stat.st_mtime), files = [
} {
"filename": path.name,
"size_bytes": int(path.stat().st_size),
"mtime": float(path.stat().st_mtime),
}
for path in sorted(las_paths, key=lambda item: item.name)
]
# 한 장일 때는 옛 형식과 같은 모양을 유지한다 — 이미 만든 캐시를 헛되이 버리지 않는다.
return files[0] if len(files) == 1 else {"files": files}
def _relative_to_project(project_root: Path, path: Path) -> str: def _relative_to_project(project_root: Path, path: Path) -> str:
@@ -108,7 +116,7 @@ def cache_ground_points(
def run_surface_analysis( def run_surface_analysis(
project_root: Path, project_root: Path,
las_path: Path, las_path: Path | Sequence[Path],
*, *,
source_filters: list[str] | None, source_filters: list[str] | None,
methods: list[str], methods: list[str],
@@ -117,6 +125,9 @@ def run_surface_analysis(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""구조화→필터→모델 빌드를 수행하고 산출 메타데이터를 반환한다. """구조화→필터→모델 빌드를 수행하고 산출 메타데이터를 반환한다.
`las_path` 지형 파일 또는 여러 장이다 여러 장이면 합친 범위로 벌을
만든다(2026-09-06 사용자 확정).
`source_filters` 비면 입력 LAS를 보고 기본 필터를 정한다(자동 전처리 경로). `source_filters` 비면 입력 LAS를 보고 기본 필터를 정한다(자동 전처리 경로).
반환 dict: 반환 dict:
@@ -132,6 +143,9 @@ def run_surface_analysis(
on_progress(percent, stage, message) on_progress(percent, stage, message)
total_started = time.monotonic() total_started = time.monotonic()
las_paths = [las_path] if isinstance(las_path, Path) else [Path(item) for item in las_path]
if not las_paths:
raise ValueError("지형 파일이 없습니다.")
stage_root = project_root / "B04_PreProcess" stage_root = project_root / "B04_PreProcess"
processed_dir = stage_root / "processed" processed_dir = stage_root / "processed"
models_dir = stage_root / "models" models_dir = stage_root / "models"
@@ -140,7 +154,7 @@ def run_surface_analysis(
# 0. 입력 세대 검증: LAS가 바뀌었으면 모든 캐시를 재계산한다 (PLAN B-2) # 0. 입력 세대 검증: LAS가 바뀌었으면 모든 캐시를 재계산한다 (PLAN B-2)
identity_path = processed_dir / "source_identity.json" identity_path = processed_dir / "source_identity.json"
current_identity = _source_identity(las_path) current_identity = _source_identity(las_paths)
stored_identity: dict[str, Any] | None = None stored_identity: dict[str, Any] | None = None
if identity_path.is_file(): if identity_path.is_file():
try: try:
@@ -154,10 +168,12 @@ def run_surface_analysis(
if rebuild or not structured_path.is_file(): if rebuild or not structured_path.is_file():
_report(10, "structurize", "LAS 구조화 중") _report(10, "structurize", "LAS 구조화 중")
step_started = time.monotonic() step_started = time.monotonic()
structured_path = structurize_las(las_path, processed_dir) structured_path = structurize_las(las_paths, processed_dir)
atomic_write_json(identity_path, current_identity) atomic_write_json(identity_path, current_identity)
logger.info( logger.info(
"B04 LAS 구조화 완료: %s (%.1fs)", las_path.name, time.monotonic() - step_started "B04 LAS 구조화 완료: %s (%.1fs)",
", ".join(path.name for path in las_paths),
time.monotonic() - step_started,
) )
else: else:
_report(10, "structurize", "구조화 캐시 재사용") _report(10, "structurize", "구조화 캐시 재사용")
@@ -254,7 +270,7 @@ def run_surface_analysis(
"z": [float(bounds[2, 0]), float(bounds[2, 1])], "z": [float(bounds[2, 0]), float(bounds[2, 1])],
} }
download_geodata( download_geodata(
project_root, processed_dir, las_bounds_dict, las_path.parent, rebuild, report=_report project_root, processed_dir, las_bounds_dict, las_paths[0].parent, rebuild, report=_report
) )
# 3-4. 도엽등고선 3D 서피스 — LAS가 있어도 참고용으로 같이 만들어 영구저장한다 # 3-4. 도엽등고선 3D 서피스 — LAS가 있어도 참고용으로 같이 만들어 영구저장한다
@@ -1,82 +1,308 @@
"""B04 LAS/LAZ 고속 구조화 엔진.""" """B04 LAS/LAZ 고속 구조화 엔진 — 여러 장을 한 벌로 병합한다 (2026-09-06 사용자 확정).
드론 라이다는 사업지가 넓으면 도엽별로 여러 장이 온다. 여기서 **합친 범위** 벌을
만들고, 단계(지면필터·모델·등고선·배수) 받는 형식이 그대로라 손대지 않는다.
점이 임계를 넘으면 **(기본 0.5m)마다 최저점 하나만** 남긴다(씨닝). 설계가 쓰는 격자가
1m(지면필터 2m·CSF 1.5m) 0.5m 설계보다 촘촘해 결과 표고가 사실상 같고, 30GB
장이 메모리 29GB 1.4GB 내려간다. 임계 아래면 원본 점을 그대로 쓴다 작은 자료의
결과는 바뀌지 않는다.
"""
import logging
import os import os
import tempfile import tempfile
from collections.abc import Callable from collections.abc import Callable, Sequence
from pathlib import Path from pathlib import Path
from typing import Any
import laspy import laspy
import numpy as np import numpy as np
from common_util.common_util_json import replace_with_retry from common_util.common_util_json import replace_with_retry
from config.config_system import SURFACE_DEFAULT_RGB_VALUE, SURFACE_LAS_CHUNK_SIZE from config.config_system import (
SURFACE_DEFAULT_RGB_VALUE,
SURFACE_LAS_CHUNK_SIZE,
SURFACE_MERGE_MAX_GAP_M,
SURFACE_THIN_CELL_SIZE_M,
SURFACE_THIN_MAX_CELLS,
SURFACE_THIN_TRIGGER_POINTS,
)
logger = logging.getLogger(__name__)
ProgressCallback = Callable[[int], None]
PathLike = str | Path
# 청크에서 점과 함께 옮기는 속성들 — 파일에 없으면 기본값이 남는다.
_ATTRIBUTES = ("intensity", "rgb", "return_number", "number_of_returns", "classification")
def _as_list(las_path: PathLike | Sequence[PathLike]) -> list[Path]:
if isinstance(las_path, (str, Path)):
return [Path(las_path)]
return [Path(item) for item in las_path]
def point_cloud_extent(path: PathLike) -> tuple[int, tuple[float, float, float, float]]:
"""머리글만 읽어 점 수와 XY 범위를 돌려준다 — 파일 크기와 무관하게 즉시 끝난다."""
with laspy.open(Path(path)) as las_file:
header = las_file.header
return int(header.point_count), (
float(header.mins[0]),
float(header.mins[1]),
float(header.maxs[0]),
float(header.maxs[1]),
)
def merge_gap_error(
paths: Sequence[PathLike], gap_m: float = SURFACE_MERGE_MAX_GAP_M
) -> str | None:
"""서로 멀리 떨어진 지형 파일이 섞였는지 — 문제면 안내 문구, 없으면 None.
다른 사업지 파일이나 좌표계가 다른 파일이 섞이면 합친 범위가 통째로 어긋나 격자가
터진다. 도엽으로 나뉜 자료는 경계가 맞닿으므로 여유를 두고 **어느 파일과도 만나지
않는 파일** 걸러 낸다.
"""
sources = _as_list(paths)
if len(sources) < 2:
return None
boxes = [(path, point_cloud_extent(path)[1]) for path in sources]
for index, (path, box) in enumerate(boxes):
near = any(
box[0] - gap_m <= other[2]
and other[0] - gap_m <= box[2]
and box[1] - gap_m <= other[3]
and other[1] - gap_m <= box[3]
for other_index, (_, other) in enumerate(boxes)
if other_index != index
)
if not near:
return (
f"지형 파일 「{path.name}」의 좌표가 다른 파일과 "
f"{int(gap_m):,}m 넘게 떨어져 있습니다."
" 같은 사업지의 파일인지, 좌표계가 같은지 확인해 주십시오."
)
return None
class _Merged:
"""합친 점을 담는 그릇 — 원본 유지형과 씨닝형이 같은 모양으로 낸다."""
def __init__(self, capacity: int) -> None:
self.xyz = np.empty((capacity, 3), dtype=np.float64)
self.intensity = np.zeros(capacity, dtype=np.uint16)
self.rgb = np.full((capacity, 3), SURFACE_DEFAULT_RGB_VALUE, dtype=np.uint8)
self.return_number = np.ones(capacity, dtype=np.uint8)
self.number_of_returns = np.ones(capacity, dtype=np.uint8)
self.classification = np.zeros(capacity, dtype=np.uint8)
self.size = 0
def arrays(self) -> dict[str, np.ndarray]:
end = self.size
return {
"xyz": self.xyz[:end],
"intensity": self.intensity[:end],
"rgb": self.rgb[:end],
"return_number": self.return_number[:end],
"number_of_returns": self.number_of_returns[:end],
"classification": self.classification[:end],
}
def append(self, columns: dict[str, np.ndarray]) -> None:
count = len(columns["x"])
section = slice(self.size, self.size + count)
self.xyz[section, 0] = columns["x"]
self.xyz[section, 1] = columns["y"]
self.xyz[section, 2] = columns["z"]
for key in _ATTRIBUTES:
if key in columns:
getattr(self, key)[section] = columns[key]
self.size += count
def _chunk_columns(chunk: Any, dimensions: set[str]) -> dict[str, np.ndarray]:
"""청크에서 쓸 값만 꺼낸다. 파일에 없는 항목은 키를 빼서 기본값이 남게 한다."""
columns: dict[str, np.ndarray] = {
"x": np.asarray(chunk.x, dtype=np.float64),
"y": np.asarray(chunk.y, dtype=np.float64),
"z": np.asarray(chunk.z, dtype=np.float64),
}
if "intensity" in dimensions:
columns["intensity"] = np.asarray(chunk.intensity, dtype=np.uint16)
if {"red", "green", "blue"}.issubset(dimensions):
colors = np.stack(
[
np.asarray(chunk.red, dtype=np.float64),
np.asarray(chunk.green, dtype=np.float64),
np.asarray(chunk.blue, dtype=np.float64),
],
axis=1,
)
if colors.size and float(colors.max()) > 255.0:
colors /= 256.0
columns["rgb"] = colors.clip(0, 255).astype(np.uint8)
if {"return_number", "number_of_returns"}.issubset(dimensions):
columns["return_number"] = np.asarray(chunk.return_number, dtype=np.uint8)
columns["number_of_returns"] = np.asarray(chunk.number_of_returns, dtype=np.uint8)
if "classification" in dimensions:
columns["classification"] = np.asarray(chunk.classification, dtype=np.uint8)
return columns
class _ThinGrid:
"""씨닝형 — **지면 분류점은 전부** 남기고, 나머지는 칸마다 최저점 하나만 남긴다.
설계 지표면을 만드는 것은 지면점이다(업체가 분류해 ASPRS class 2). 점을 하나도
버리지 않으므로 **지면 결과는 씨닝 전과 완전히 같다**(2026-09-06 용화 실측: 1m 지면
격자 141,969 전부 표고 차이 0). 지면점은 원본의 2~3%뿐이라 남겨도 가볍다.
나머지(수목·구조물·잡음) 칸마다 최저점만 남긴다 분류가 없는 자료에서 CSF·PMF가
지면을 찾을 밑그림으로 충분하다(CSF 간격 1.5m > 0.5m).
청크 안에서 같은 칸이 여러 나오면 뒤에 값이 이겨 최저점이 아니게 된다.
그래서 청크를 (, 표고) 정렬해 **칸마다 ** 골라 격자와 견준다.
"""
#: ASPRS 지면 분류 코드.
GROUND_CLASS = 2
def __init__(self, bounds: np.ndarray, cell_size: float) -> None:
self.cell_size = cell_size
self.x_min = float(bounds[0, 0])
self.y_min = float(bounds[1, 0])
self.width = int(np.ceil((float(bounds[0, 1]) - self.x_min) / cell_size)) + 1
self.height = int(np.ceil((float(bounds[1, 1]) - self.y_min) / cell_size)) + 1
cells = self.width * self.height
if cells > SURFACE_THIN_MAX_CELLS:
raise ValueError(
"지형 자료의 합친 범위가 너무 넓습니다."
" 같은 사업지의 파일인지, 좌표계가 같은지 확인해 주십시오."
)
self.best_z = np.full(cells, np.inf, dtype=np.float64)
self.x = np.zeros(cells, dtype=np.float64)
self.y = np.zeros(cells, dtype=np.float64)
self.intensity = np.zeros(cells, dtype=np.uint16)
self.rgb = np.full((cells, 3), SURFACE_DEFAULT_RGB_VALUE, dtype=np.uint8)
self.return_number = np.ones(cells, dtype=np.uint8)
self.number_of_returns = np.ones(cells, dtype=np.uint8)
self.classification = np.zeros(cells, dtype=np.uint8)
# 그대로 남길 지면점 — 청크마다 모아 두었다가 마지막에 잇는다.
self.ground: list[dict[str, np.ndarray]] = []
def add(self, columns: dict[str, np.ndarray]) -> None:
classification = columns.get("classification")
if classification is not None:
is_ground = classification == self.GROUND_CLASS
if is_ground.any():
self.ground.append({key: value[is_ground] for key, value in columns.items()})
keep = ~is_ground
columns = {key: value[keep] for key, value in columns.items()}
x, y, z = columns["x"], columns["y"], columns["z"]
if not len(x):
return
grid_x = np.clip(((x - self.x_min) / self.cell_size).astype(np.int64), 0, self.width - 1)
grid_y = np.clip(((y - self.y_min) / self.cell_size).astype(np.int64), 0, self.height - 1)
cell = grid_y * self.width + grid_x
order = np.lexsort((z, cell))
sorted_cell = cell[order]
first = np.ones(len(order), dtype=bool)
first[1:] = sorted_cell[1:] != sorted_cell[:-1]
candidate = order[first]
candidate_cell = cell[candidate]
better = z[candidate] < self.best_z[candidate_cell]
chosen = candidate[better]
target = candidate_cell[better]
self.best_z[target] = z[chosen]
self.x[target] = x[chosen]
self.y[target] = y[chosen]
for key in _ATTRIBUTES:
if key in columns:
getattr(self, key)[target] = columns[key][chosen]
def collect(self) -> _Merged:
occupied = np.flatnonzero(np.isfinite(self.best_z))
ground_count = sum(len(item["x"]) for item in self.ground)
merged = _Merged(len(occupied) + ground_count)
merged.xyz[: len(occupied), 0] = self.x[occupied]
merged.xyz[: len(occupied), 1] = self.y[occupied]
merged.xyz[: len(occupied), 2] = self.best_z[occupied]
for key in _ATTRIBUTES:
getattr(merged, key)[: len(occupied)] = getattr(self, key)[occupied]
merged.size = len(occupied)
for item in self.ground:
merged.append(item)
return merged
def _headers(sources: list[Path]) -> tuple[int, np.ndarray, bool]:
"""전체 점 수·합친 범위(3x2)·색 보유 여부를 머리글만 읽어 구한다."""
total = 0
has_rgb = False
mins = np.full(3, np.inf, dtype=np.float64)
maxs = np.full(3, -np.inf, dtype=np.float64)
for source in sources:
with laspy.open(source) as las_file:
header = las_file.header
total += int(header.point_count)
mins = np.minimum(mins, np.asarray(header.mins, dtype=np.float64))
maxs = np.maximum(maxs, np.asarray(header.maxs, dtype=np.float64))
dimensions = set(header.point_format.dimension_names)
has_rgb = has_rgb or {"red", "green", "blue"}.issubset(dimensions)
if not np.isfinite(mins).all():
mins = np.zeros(3, dtype=np.float64)
maxs = np.zeros(3, dtype=np.float64)
return total, np.column_stack((mins, maxs)), has_rgb
def _merge_sources(
sources: list[Path],
total_points: int,
bounds: np.ndarray,
thin: bool,
progress_callback: ProgressCallback | None,
) -> _Merged:
grid = _ThinGrid(bounds, SURFACE_THIN_CELL_SIZE_M) if thin else None
merged = _Merged(total_points) if grid is None else None
done = 0
for source in sources:
with laspy.open(source) as las_file:
dimensions = set(las_file.header.point_format.dimension_names)
for chunk in las_file.chunk_iterator(SURFACE_LAS_CHUNK_SIZE):
columns = _chunk_columns(chunk, dimensions)
if grid is not None:
grid.add(columns)
else:
merged.append(columns)
done += len(columns["x"])
if progress_callback:
progress_callback(int(done / total_points * 100) if total_points else 100)
return grid.collect() if grid is not None else merged
def structurize_las( def structurize_las(
las_path: str | Path, las_path: PathLike | Sequence[PathLike],
output_dir: str | Path, output_dir: str | Path,
progress_callback: Callable[[int], None] | None = None, progress_callback: ProgressCallback | None = None,
) -> Path: ) -> Path:
"""LAS/LAZ 속성을 청크로 읽어 B04 structured.npz로 원자적 저장한다.""" """지형 파일 한 장 또는 여러 장을 청크로 읽어 B04 structured.npz로 원자적 저장한다."""
source = Path(las_path) sources = _as_list(las_path)
if not sources:
raise ValueError("구조화할 지형 파일이 없습니다.")
target_dir = Path(output_dir) target_dir = Path(output_dir)
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / "structured.npz" target = target_dir / "structured.npz"
with laspy.open(source) as las_file: total_points, bounds, has_rgb = _headers(sources)
header = las_file.header thin = total_points > SURFACE_THIN_TRIGGER_POINTS
total_points = int(header.point_count) merged = _merge_sources(sources, total_points, bounds, thin, progress_callback)
point_format = header.point_format logger.info(
dimensions = set(point_format.dimension_names) "B04 구조화: 파일 %d장 원본 %d점 → 저장 %d점 (씨닝 %s)",
has_rgb = {"red", "green", "blue"}.issubset(dimensions) len(sources),
has_intensity = "intensity" in dimensions total_points,
has_returns = {"return_number", "number_of_returns"}.issubset(dimensions) merged.size,
has_classification = "classification" in dimensions f"{SURFACE_THIN_CELL_SIZE_M}m 칸" if thin else "없음",
bounds = np.array( )
[
[float(header.mins[0]), float(header.maxs[0])],
[float(header.mins[1]), float(header.maxs[1])],
[float(header.mins[2]), float(header.maxs[2])],
],
dtype=np.float64,
)
xyz = np.empty((total_points, 3), dtype=np.float64)
intensity = np.zeros(total_points, dtype=np.uint16)
rgb = np.full((total_points, 3), SURFACE_DEFAULT_RGB_VALUE, dtype=np.uint8)
return_number = np.ones(total_points, dtype=np.uint8)
number_of_returns = np.ones(total_points, dtype=np.uint8)
classification = np.zeros(total_points, dtype=np.uint8)
offset = 0
for chunk in las_file.chunk_iterator(SURFACE_LAS_CHUNK_SIZE):
chunk_size = len(chunk)
section = slice(offset, offset + chunk_size)
xyz[section, 0] = np.asarray(chunk.x, dtype=np.float64)
xyz[section, 1] = np.asarray(chunk.y, dtype=np.float64)
xyz[section, 2] = np.asarray(chunk.z, dtype=np.float64)
if has_intensity:
intensity[section] = np.asarray(chunk.intensity, dtype=np.uint16)
if has_rgb:
colors = np.stack(
[
np.asarray(chunk.red, dtype=np.float64),
np.asarray(chunk.green, dtype=np.float64),
np.asarray(chunk.blue, dtype=np.float64),
],
axis=1,
)
if colors.size and float(colors.max()) > 255.0:
colors /= 256.0
rgb[section] = colors.clip(0, 255).astype(np.uint8)
if has_returns:
return_number[section] = np.asarray(chunk.return_number, dtype=np.uint8)
number_of_returns[section] = np.asarray(chunk.number_of_returns, dtype=np.uint8)
if has_classification:
classification[section] = np.asarray(chunk.classification, dtype=np.uint8)
offset += chunk_size
if progress_callback:
progress_callback(int(offset / total_points * 100) if total_points else 100)
temporary_path: Path | None = None temporary_path: Path | None = None
try: try:
@@ -90,14 +316,12 @@ def structurize_las(
temporary_path = Path(temporary.name) temporary_path = Path(temporary.name)
np.savez_compressed( np.savez_compressed(
temporary, temporary,
xyz=xyz, **merged.arrays(),
intensity=intensity,
rgb=rgb,
return_number=return_number,
number_of_returns=number_of_returns,
classification=classification,
bounds=bounds, bounds=bounds,
total_points=np.array([total_points], dtype=np.int64), total_points=np.array([merged.size], dtype=np.int64),
source_point_count=np.array([total_points], dtype=np.int64),
source_file_count=np.array([len(sources)], dtype=np.int64),
thinned=np.array([int(thin)], dtype=np.int8),
has_rgb=np.array([int(has_rgb)], dtype=np.int8), has_rgb=np.array([int(has_rgb)], dtype=np.int8),
) )
temporary.flush() temporary.flush()
@@ -109,6 +333,6 @@ def structurize_las(
if temporary_path is not None: if temporary_path is not None:
temporary_path.unlink(missing_ok=True) temporary_path.unlink(missing_ok=True)
if progress_callback and total_points == 0: if progress_callback:
progress_callback(100) progress_callback(100)
return target return target
+20 -1
View File
@@ -6,7 +6,7 @@ terrain_layers(지형 레이어) 테이블에 메타데이터와 상대 경로
""" """
import json import json
from pathlib import PurePosixPath from pathlib import Path, PurePosixPath
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -230,6 +230,25 @@ async def list_project_point_cloud_inputs(
] ]
async def list_project_point_cloud_paths(
connection: aiomysql.Connection, project_id: UUID, project_root: Path
) -> list[Path]:
"""전처리가 병합할 지형 파일 경로 목록 — 실제로 있는 파일만 (2026-09-06 다중 입력).
교체된 (`SUPERSEDED`) 조회에서 이미 빠지므로, 지금 살아 있는 파일만 남는다.
"""
rows = await list_project_point_cloud_inputs(connection, project_id)
paths: list[Path] = []
for row in rows:
raw = str(row.get("raw_file_path") or "")
if not raw:
continue
path = project_root / Path(raw)
if path.is_file() and path not in paths:
paths.append(path)
return paths
async def list_surface_models( async def list_surface_models(
connection: aiomysql.Connection, project_id: UUID connection: aiomysql.Connection, project_id: UUID
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
+6 -1
View File
@@ -26,6 +26,7 @@ from B04_PreProcess.B04_PreProcess_Repository import (
clear_confirmed_surface_models, clear_confirmed_surface_models,
get_input_file, get_input_file,
list_project_point_cloud_inputs, list_project_point_cloud_inputs,
list_project_point_cloud_paths,
list_surface_models, list_surface_models,
save_surface_analysis_to_db, save_surface_analysis_to_db,
) )
@@ -117,6 +118,10 @@ async def analyze_surface(
status_code=404, status_code=404,
content={"status": "error", "message": "원본 LAS 파일을 찾을 수 없습니다."}, content={"status": "error", "message": "원본 LAS 파일을 찾을 수 없습니다."},
) )
# 지형 파일이 여러 장이면 합쳐서 다시 만든다 — 자동 전처리와 같은 대상을 쓴다.
terrain_paths = await list_project_point_cloud_paths(
connection, project_id, project_root
)
# 분석 시작 진행률 기록 (별도 스레드의 콜백은 파일에만 원자적 기록). # 분석 시작 진행률 기록 (별도 스레드의 콜백은 파일에만 원자적 기록).
write_surface_progress(project_root, 5, "analyzing", "WF1 분석을 시작합니다.") write_surface_progress(project_root, 5, "analyzing", "WF1 분석을 시작합니다.")
@@ -128,7 +133,7 @@ async def analyze_surface(
result = await asyncio.to_thread( result = await asyncio.to_thread(
run_surface_analysis, run_surface_analysis,
project_root, project_root,
las_path, terrain_paths or [las_path],
source_filters=source_filters, source_filters=source_filters,
methods=methods, methods=methods,
force=request.force, force=request.force,
+84
View File
@@ -0,0 +1,84 @@
"""시스템 로그 기록·정리 한 곳 (2026-09-06 사용자 확정).
같은 INSERT 문이 여섯 자리에 흩어져 있었고, 대상(무엇에 일인가) 접속 정보(어디서
했는가) 칸만 있고 값이 비어 있었다. 기록은 함수 하나로 모은다.
보관 기간은 `AUDIT_LOG_RETENTION_DAYS`(기본 365) 사고 추적에 1년이면 충분하다는
사용자 판단(2026-09-06). 임시 보관함 정리 루프가 함께 지운다.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
from config.config_db import get_db_pool
from config.config_system import AUDIT_LOG_RETENTION_DAYS
logger = logging.getLogger(__name__)
# 브라우저 문자열은 길다 — 표 칸은 TEXT 지만 화면·로그가 감당할 만큼만 자른다.
_USER_AGENT_MAX = 300
def request_origin(request: Any | None) -> tuple[str | None, str | None]:
"""요청에서 접속 주소와 브라우저 문자열을 꺼낸다. 없으면 (None, None).
프록시 뒤에서는 `X-Forwarded-For` ** 주소** 실제 사용자다.
"""
if request is None:
return None, None
try:
forwarded = request.headers.get("x-forwarded-for")
address = forwarded.split(",")[0].strip() if forwarded else None
if not address and request.client is not None:
address = request.client.host
agent = (request.headers.get("user-agent") or "")[:_USER_AGENT_MAX] or None
return address, agent
except Exception: # 기록이 본 작업을 막으면 안 된다.
return None, None
async def record_audit(
cursor: Any,
*,
actor_id: int,
action: str,
resource_type: str | None = None,
resource_ref: str | int | None = None,
request: Any | None = None,
) -> None:
"""시스템 로그 한 줄을 적는다 — 호출부의 트랜잭션(cursor)에 얹는다.
`resource_ref` 프로젝트 UUID 처럼 문자열이어도 되고 숫자여도 된다. 숫자면
`resource_id` 칸에도 같이 넣어 예전 기록과 같은 모양을 지킨다.
"""
reference = None if resource_ref is None else str(resource_ref)
numeric = int(resource_ref) if isinstance(resource_ref, int) else None
address, agent = request_origin(request)
await cursor.execute(
"""INSERT INTO system_audit_logs
(user_id, action, resource_type, resource_id, resource_ref, ip_address, user_agent)
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
(actor_id, action, resource_type, numeric, reference, address, agent),
)
async def purge_expired_audit_logs() -> int:
"""보관 기간이 지난 시스템 로그를 지운다. 지운 줄 수를 돌려준다."""
cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, AUDIT_LOG_RETENTION_DAYS))
pool = get_db_pool()
try:
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute("DELETE FROM system_audit_logs WHERE timestamp < %s", (cutoff,))
removed = cursor.rowcount
if removed:
await connection.commit()
logger.info(
"시스템 로그 정리: %d건 삭제 (보관 %d일)", removed, AUDIT_LOG_RETENTION_DAYS
)
return removed
except Exception:
logger.exception("시스템 로그 정리 실패")
return 0
+10 -5
View File
@@ -8,14 +8,16 @@
import logging import logging
import shutil import shutil
from typing import Any
from common_util.common_util_audit import record_audit
from common_util.common_util_storage import resolve_project_root_for_delete from common_util.common_util_storage import resolve_project_root_for_delete
from config.config_db import get_db_pool from config.config_db import get_db_pool
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def hard_delete_project(project_id: str, actor_id: int) -> bool: async def hard_delete_project(project_id: str, actor_id: int, request: Any | None = None) -> bool:
"""프로젝트를 DB와 영구저장소에서 완전히 지운다. 되돌릴 수 없다. """프로젝트를 DB와 영구저장소에서 완전히 지운다. 되돌릴 수 없다.
자식 테이블은 나열하지 않는다 `projects.id` 참조하는 테이블이 전부 자식 테이블은 나열하지 않는다 `projects.id` 참조하는 테이블이 전부
@@ -60,10 +62,13 @@ async def hard_delete_project(project_id: str, actor_id: int) -> bool:
await connection.rollback() await connection.rollback()
return False return False
# 감사 기록은 프로젝트가 사라진 뒤에도 남는다. resource_id는 FK가 없어 고아가 되지 않는다. # 감사 기록은 프로젝트가 사라진 뒤에도 남는다. resource_id는 FK가 없어 고아가 되지 않는다.
await cursor.execute( await record_audit(
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) cursor,
VALUES (%s, 'PROJECT_HARD_DELETE', 'project', NULL)""", actor_id=actor_id,
(actor_id,), action="PROJECT_HARD_DELETE",
resource_type="project",
resource_ref=project_id,
request=request,
) )
await connection.commit() await connection.commit()
+3
View File
@@ -14,6 +14,7 @@ from B03_FileInput.B03_FileInput_Repository_Temp import (
delete_temp_batch, delete_temp_batch,
list_expired_temp_batches, list_expired_temp_batches,
) )
from common_util.common_util_audit import purge_expired_audit_logs
from common_util.common_util_storage import resolve_temp_batch_path, temp_upload_root from common_util.common_util_storage import resolve_temp_batch_path, temp_upload_root
from config.config_db import get_db_pool from config.config_db import get_db_pool
from config.config_system import ( from config.config_system import (
@@ -81,4 +82,6 @@ async def cleanup_expired_temp_uploads_loop() -> None:
removed = await cleanup_expired_temp_uploads() removed = await cleanup_expired_temp_uploads()
if removed: if removed:
logger.info("임시 보관함 정리 완료: %d건 삭제", removed) logger.info("임시 보관함 정리 완료: %d건 삭제", removed)
# 시스템 로그 보관 기간 정리도 같은 주기에 얹는다 — 루프를 따로 두지 않는다.
await purge_expired_audit_logs()
await asyncio.sleep(interval_seconds) await asyncio.sleep(interval_seconds)
+2
View File
@@ -98,6 +98,8 @@ CHUNK_RETENTION_HOURS = int(os.getenv("CHUNK_RETENTION_HOURS", "24"))
# 운영하며 조정할 값이라 여기서 관리한다(2026-08-08 사용자 지시). # 운영하며 조정할 값이라 여기서 관리한다(2026-08-08 사용자 지시).
TEMP_UPLOAD_DIR_NAME = os.getenv("TEMP_UPLOAD_DIR_NAME", "tmp") TEMP_UPLOAD_DIR_NAME = os.getenv("TEMP_UPLOAD_DIR_NAME", "tmp")
TEMP_UPLOAD_RETENTION_DAYS = int(os.getenv("TEMP_UPLOAD_RETENTION_DAYS", "30")) TEMP_UPLOAD_RETENTION_DAYS = int(os.getenv("TEMP_UPLOAD_RETENTION_DAYS", "30"))
# 시스템 로그(누가 무엇을 했나) 보관 기간 — 사고 추적에 1년 (2026-09-06 사용자 확정).
AUDIT_LOG_RETENTION_DAYS = int(os.getenv("AUDIT_LOG_RETENTION_DAYS", "365"))
TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS = int(os.getenv("TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS", "6")) TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS = int(os.getenv("TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS", "6"))
MERGE_TIMEOUT_SECONDS = int(os.getenv("MERGE_TIMEOUT_SECONDS", "3600")) MERGE_TIMEOUT_SECONDS = int(os.getenv("MERGE_TIMEOUT_SECONDS", "3600"))
SEND_ANALYSIS_COMPLETION_EMAIL = ( SEND_ANALYSIS_COMPLETION_EMAIL = (
+14
View File
@@ -14,6 +14,20 @@ MESH_SMOOTHING_ITERATIONS = int(os.getenv("MESH_SMOOTHING_ITERATIONS", "0"))
SURFACE_LAS_CHUNK_SIZE = int(os.getenv("SURFACE_LAS_CHUNK_SIZE", "500000")) SURFACE_LAS_CHUNK_SIZE = int(os.getenv("SURFACE_LAS_CHUNK_SIZE", "500000"))
SURFACE_DEFAULT_RGB_VALUE = int(os.getenv("SURFACE_DEFAULT_RGB_VALUE", "128")) SURFACE_DEFAULT_RGB_VALUE = int(os.getenv("SURFACE_DEFAULT_RGB_VALUE", "128"))
SURFACE_GRID_CELL_SIZE_M = float(os.getenv("SURFACE_GRID_CELL_SIZE_M", "2.0")) SURFACE_GRID_CELL_SIZE_M = float(os.getenv("SURFACE_GRID_CELL_SIZE_M", "2.0"))
# 지형 파일 여러 장 병합 (2026-09-06 사용자 확정)
# ─────────────────────────────────────────────────────────────────────────
# 다른 사업지 파일이 섞여 들어오면 합친 범위가 통째로 어긋난다 — 어느 파일과도 이
# 거리 안에서 만나지 않는 파일은 업로드에서 막는다. 임도는 길어도 2~3km 라 5km 면
# 넉넉하다(2026-09-06 사용자 확정). 도엽이 나뉜 자료는 경계가 맞닿아 걸리지 않는다.
SURFACE_MERGE_MAX_GAP_M = float(os.getenv("SURFACE_MERGE_MAX_GAP_M", "5000"))
# 점이 이 수를 넘으면 아래 칸 크기로 씨닝한다(칸마다 최저점 하나). 넘지 않으면 원본
# 그대로 쓴다 — 작은 자료의 결과는 바뀌지 않는다. 1억점 = 메모리 약 3.2GB.
SURFACE_THIN_TRIGGER_POINTS = int(os.getenv("SURFACE_THIN_TRIGGER_POINTS", "100000000"))
# 씨닝 칸 크기(m). 설계 격자 1m·지면필터 2m·CSF 천 1.5m 보다 촘촘해야 결과가 안 변한다.
SURFACE_THIN_CELL_SIZE_M = float(os.getenv("SURFACE_THIN_CELL_SIZE_M", "0.5"))
# 씨닝 격자가 이보다 많아지면 범위가 비정상이다(먼 파일이 섞였거나 좌표계 불일치).
SURFACE_THIN_MAX_CELLS = int(os.getenv("SURFACE_THIN_MAX_CELLS", "400000000"))
SURFACE_GRID_HEIGHT_THRESHOLD_M = float(os.getenv("SURFACE_GRID_HEIGHT_THRESHOLD_M", "1.5")) SURFACE_GRID_HEIGHT_THRESHOLD_M = float(os.getenv("SURFACE_GRID_HEIGHT_THRESHOLD_M", "1.5"))
# CSF (Cloth Simulation Filter) 지면 분류 파라미터 # CSF (Cloth Simulation Filter) 지면 분류 파라미터
+13
View File
@@ -0,0 +1,13 @@
-- 017_audit_log_detail.sql
-- 시스템 로그에 「무엇에」 한 일인지와 「어디서」 했는지를 남긴다 (2026-09-06 사용자 확정).
--
-- 기존 `resource_id` 는 INT 라 프로젝트 id(UUID 문자열)를 담지 못해 늘 NULL 로 들어갔다.
-- 문자 칸을 따로 두어 프로젝트·회사·사용자 어느 쪽이든 그대로 적는다.
-- IP·브라우저 칸(`ip_address`·`user_agent`)은 004 에서 이미 만들어 두었으나 값을 넣는
-- 코드가 없었다 — 이 마이그레이션 뒤부터 기록한다.
ALTER TABLE system_audit_logs
ADD COLUMN IF NOT EXISTS resource_ref VARCHAR(64) NULL
COMMENT '대상 식별자 (프로젝트 UUID 등 문자열). 숫자 대상은 resource_id 와 같이 채운다';
-- 보관 기간(기본 365일) 정리가 날짜로 훑으므로 인덱스는 004 의 timestamp 인덱스를 그대로 쓴다.
+5 -1
View File
@@ -125,7 +125,6 @@ export const ui_locales_b1 = {
B01_Dashboard_SaveProfile: ["기본정보 저장", "Save profile"], B01_Dashboard_SaveProfile: ["기본정보 저장", "Save profile"],
B01_Dashboard_Table_Project: ["프로젝트명", "Project"], B01_Dashboard_Table_Project: ["프로젝트명", "Project"],
B01_Dashboard_Table_Region: ["지역", "Region"], B01_Dashboard_Table_Region: ["지역", "Region"],
B01_Dashboard_Table_Progress: ["진행도", "Progress"],
B01_Dashboard_Table_Workflow: ["워크플로우", "Workflow"], B01_Dashboard_Table_Workflow: ["워크플로우", "Workflow"],
B01_Dashboard_Table_Updated: ["수정일", "Updated"], B01_Dashboard_Table_Updated: ["수정일", "Updated"],
B01_Dashboard_Table_Email: ["이메일", "Email"], B01_Dashboard_Table_Email: ["이메일", "Email"],
@@ -137,12 +136,17 @@ export const ui_locales_b1 = {
B01_Dashboard_Table_Company: ["회사명", "Company"], B01_Dashboard_Table_Company: ["회사명", "Company"],
B01_Dashboard_Table_Requested: ["신청일", "Requested"], B01_Dashboard_Table_Requested: ["신청일", "Requested"],
B01_Dashboard_Table_Action: ["관리", "Action"], B01_Dashboard_Table_Action: ["관리", "Action"],
B01_Dashboard_Table_Event: ["동작", "Event"],
B01_Dashboard_Table_Target: ["대상", "Target"],
B01_Dashboard_Table_Origin: ["접속 주소", "From"],
B01_Dashboard_Table_When: ["일시", "When"],
B01_Dashboard_Table_Owner: ["소유자", "Owner"], B01_Dashboard_Table_Owner: ["소유자", "Owner"],
B01_Dashboard_Field_BusinessNumber: ["사업자등록번호", "Business number"], B01_Dashboard_Field_BusinessNumber: ["사업자등록번호", "Business number"],
B01_Dashboard_Field_Address: ["주소", "Address"], B01_Dashboard_Field_Address: ["주소", "Address"],
B01_Dashboard_Field_Owner: ["대표자명", "Owner"], B01_Dashboard_Field_Owner: ["대표자명", "Owner"],
B01_Dashboard_Field_Search: ["검색어", "Search"], B01_Dashboard_Field_Search: ["검색어", "Search"],
B01_Dashboard_Field_MemberEmail: ["팀원 이메일", "Member email"], B01_Dashboard_Field_MemberEmail: ["팀원 이메일", "Member email"],
B01_Dashboard_Field_Email: ["이메일", "Email"],
B01_Dashboard_Metric_Cpu: ["CPU", "CPU"], B01_Dashboard_Metric_Cpu: ["CPU", "CPU"],
B01_Dashboard_Metric_Memory: ["메모리", "Memory"], B01_Dashboard_Metric_Memory: ["메모리", "Memory"],
B01_Dashboard_Metric_Disk: ["디스크", "Disk"], B01_Dashboard_Metric_Disk: ["디스크", "Disk"],