Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
This commit is contained in:
@@ -251,6 +251,13 @@ const SHELL_CSS = `
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
/* 사용자명·로그아웃(또는 로그인·회원가입) 버튼이 서로 붙어 보이던 것을 띄운다
|
||||
(2026-09-06 사용자 지적) — 이 칸은 나중에 채워지므로 자체 간격이 필요하다. */
|
||||
.app-actions__auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
.app-outlet {
|
||||
min-height: calc(100vh - 64px - 56px);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@ export interface ProjectItem {
|
||||
member_user_ids?: number[];
|
||||
owner_name?: string | null;
|
||||
workflow_stage: number;
|
||||
progress_percent: number;
|
||||
workflow_state?: WorkflowState;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
@@ -118,6 +117,9 @@ export interface AuditLog {
|
||||
action: string;
|
||||
resource_type?: string | null;
|
||||
resource_id?: number | null;
|
||||
/** 대상 식별자 문자열 — 프로젝트는 UUID 라 숫자 칸에 못 담는다 (2026-09-06). */
|
||||
resource_ref?: string | null;
|
||||
ip_address?: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any
|
||||
import aiomysql
|
||||
import psutil
|
||||
|
||||
from common_util.common_util_audit import record_audit
|
||||
from config.config_db import get_db_pool
|
||||
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"
|
||||
|
||||
|
||||
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"
|
||||
if value in {"WF1_ANALYZING", "WF1_FAILED"}:
|
||||
return 1, round(1 / 7 * 100)
|
||||
return 1
|
||||
order = [
|
||||
("FILE_UPLOADED", 1),
|
||||
("WF1_COMPLETE", 2),
|
||||
@@ -61,12 +68,11 @@ def _stage_from_status(status: str | None) -> tuple[int, int]:
|
||||
for token, idx in order:
|
||||
if token in value:
|
||||
stage = max(stage, idx)
|
||||
return stage, round(stage / 7 * 100)
|
||||
return stage
|
||||
|
||||
|
||||
def _project_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
stage, progress = _stage_from_status(row.get("status"))
|
||||
return {**row, "workflow_stage": stage, "progress_percent": progress}
|
||||
return {**row, "workflow_stage": _stage_from_status(row.get("status"))}
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
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
|
||||
else:
|
||||
await cursor.execute(
|
||||
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id)
|
||||
VALUES (%s, 'PROJECT_UPDATE', 'project', NULL)""",
|
||||
(actor_id,),
|
||||
await record_audit(
|
||||
cursor,
|
||||
actor_id=actor_id,
|
||||
action="PROJECT_UPDATE",
|
||||
resource_type="project",
|
||||
resource_ref=project_id,
|
||||
request=request,
|
||||
)
|
||||
await connection.commit()
|
||||
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()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await connection.begin()
|
||||
@@ -315,10 +326,13 @@ async def soft_delete_project(project_id: str, actor_id: int) -> bool:
|
||||
)
|
||||
changed = cursor.rowcount > 0
|
||||
if changed:
|
||||
await cursor.execute(
|
||||
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id)
|
||||
VALUES (%s, 'PROJECT_DELETE', 'project', NULL)""",
|
||||
(actor_id,),
|
||||
await record_audit(
|
||||
cursor,
|
||||
actor_id=actor_id,
|
||||
action="PROJECT_DELETE",
|
||||
resource_type="project",
|
||||
resource_ref=project_id,
|
||||
request=request,
|
||||
)
|
||||
await connection.commit()
|
||||
return changed
|
||||
@@ -442,7 +456,7 @@ async def list_audit_logs(limit: int, offset: int) -> dict[str, Any]:
|
||||
total = (await cursor.fetchone())["total"]
|
||||
await cursor.execute(
|
||||
"""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
|
||||
ORDER BY l.timestamp DESC LIMIT %s OFFSET %s""",
|
||||
(limit, offset),
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any
|
||||
|
||||
import aiomysql
|
||||
|
||||
from common_util.common_util_audit import record_audit
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
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())
|
||||
|
||||
|
||||
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()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
try:
|
||||
@@ -104,10 +107,13 @@ async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]:
|
||||
WHERE id = %s""",
|
||||
(company_id, user_id),
|
||||
)
|
||||
await cursor.execute(
|
||||
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id)
|
||||
VALUES (%s, 'COMPANY_CREATE', 'company', %s)""",
|
||||
(user_id, company_id),
|
||||
await record_audit(
|
||||
cursor,
|
||||
actor_id=user_id,
|
||||
action="COMPANY_CREATE",
|
||||
resource_type="company",
|
||||
resource_ref=company_id,
|
||||
request=request,
|
||||
)
|
||||
await connection.commit()
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
try:
|
||||
@@ -135,10 +143,13 @@ async def create_system_company(actor_id: int, data: dict[str, Any]) -> dict[str
|
||||
),
|
||||
)
|
||||
company_id = cursor.lastrowid
|
||||
await cursor.execute(
|
||||
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id)
|
||||
VALUES (%s, 'COMPANY_CREATE', 'company', %s)""",
|
||||
(actor_id, company_id),
|
||||
await record_audit(
|
||||
cursor,
|
||||
actor_id=actor_id,
|
||||
action="COMPANY_CREATE",
|
||||
resource_type="company",
|
||||
resource_ref=company_id,
|
||||
request=request,
|
||||
)
|
||||
await connection.commit()
|
||||
return {"company_id": company_id, "status": "ACTIVE"}
|
||||
|
||||
@@ -13,6 +13,7 @@ from fastapi import (
|
||||
HTTPException,
|
||||
Path,
|
||||
Query,
|
||||
Request,
|
||||
Response,
|
||||
UploadFile,
|
||||
)
|
||||
@@ -196,10 +197,11 @@ async def user_company_search(
|
||||
|
||||
@router.post("/user/company/create")
|
||||
async def user_company_create(
|
||||
request: Request,
|
||||
payload: CreateCompanyRequest,
|
||||
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}
|
||||
|
||||
|
||||
@@ -369,6 +371,7 @@ async def admin_projects(session: dict[str, Any] = Depends(require_company_admin
|
||||
@router.put("/projects/{project_id}")
|
||||
async def dashboard_update_project(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
payload: UpdateProjectRequest,
|
||||
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)
|
||||
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="프로젝트를 찾을 수 없습니다.")
|
||||
if member_ids is not None:
|
||||
# 만든 사람은 늘 참여자로 남는다.
|
||||
@@ -398,6 +401,7 @@ async def dashboard_update_project(
|
||||
@router.delete("/projects/{project_id}")
|
||||
async def dashboard_delete_project(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
project = await get_project(project_id)
|
||||
@@ -413,7 +417,7 @@ async def dashboard_delete_project(
|
||||
|
||||
# 개발 PC에서만 하드 삭제. 배포 기본값은 지금까지처럼 소프트 삭제다.
|
||||
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="프로젝트를 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
@@ -426,10 +430,11 @@ async def system_companies(session: dict[str, Any] = Depends(require_system_admi
|
||||
|
||||
@router.post("/admin/companies")
|
||||
async def system_create_company(
|
||||
request: Request,
|
||||
payload: CreateCompanyRequest,
|
||||
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}
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,13 @@ import {
|
||||
openEditUserModal,
|
||||
} from "./B01_Dashboard_UI_Modals";
|
||||
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 {
|
||||
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 {
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Email"),
|
||||
L("B01_Dashboard_Table_Action"),
|
||||
L("B01_Dashboard_Table_Updated"),
|
||||
// 관리 버튼 열과 같은 말(「관리」)을 돌려 쓰던 것을 갈랐다 (2026-09-06 사용자 지적).
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,32 @@ export function formatDate(value?: string | null): string {
|
||||
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 사용자 지시)
|
||||
*
|
||||
@@ -149,13 +175,17 @@ export function attachModalDismiss(
|
||||
* (2026-09-06 사용자 지시, 템플릿 일원화). 순서는 이름 > 직급 > 이메일 > 부서 > 전화이며
|
||||
* 이메일은 계정 식별자라 읽기 전용이다.
|
||||
*/
|
||||
export function buildUserFields(source: {
|
||||
name: string;
|
||||
email?: string;
|
||||
position?: string | null;
|
||||
department?: string | null;
|
||||
phone?: string | null;
|
||||
}): {
|
||||
export function buildUserFields(
|
||||
source: {
|
||||
name: string;
|
||||
email?: string;
|
||||
position?: string | null;
|
||||
department?: string | null;
|
||||
phone?: string | null;
|
||||
},
|
||||
/** 본인 정보 화면에서는 「팀원 이메일」이 아니라 「이메일」이다 (2026-09-06 사용자 지시). */
|
||||
options: { self?: boolean } = {},
|
||||
): {
|
||||
grid: HTMLElement;
|
||||
validate: () => boolean;
|
||||
values: () => {
|
||||
@@ -175,7 +205,7 @@ export function buildUserFields(source: {
|
||||
value: source.position ?? "",
|
||||
});
|
||||
const email = createInputField({
|
||||
label: L("B01_Dashboard_Field_MemberEmail"),
|
||||
label: L(options.self ? "B01_Dashboard_Field_Email" : "B01_Dashboard_Field_MemberEmail"),
|
||||
type: "email",
|
||||
value: source.email ?? "",
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { section } from "@ui/ui_template_general_blocks";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import {
|
||||
fetchAllCompanies,
|
||||
@@ -130,9 +131,8 @@ async function loadRoleData(state: DashboardState): Promise<void> {
|
||||
}
|
||||
|
||||
function buildPage(state: DashboardState): HTMLElement {
|
||||
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
|
||||
const page = document.createElement("div");
|
||||
page.className = "b01-dashboard";
|
||||
page.append(buildHeader(state.user));
|
||||
|
||||
const grid = document.createElement("div");
|
||||
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));
|
||||
}
|
||||
page.append(grid);
|
||||
return page;
|
||||
}
|
||||
|
||||
function buildHeader(user: DashboardUser): HTMLElement {
|
||||
const header = document.createElement("header");
|
||||
header.className = "b01-dashboard__header";
|
||||
const text = document.createElement("div");
|
||||
const title = document.createElement("h1");
|
||||
title.className = "b01-dashboard__title";
|
||||
title.textContent = L("B01_Dashboard_Title");
|
||||
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");
|
||||
const layout = createGeneralLayout({
|
||||
pageClass: "b01-dashboard",
|
||||
title: L("B01_Dashboard_Title"),
|
||||
subtitle: L("B01_Dashboard_Subtitle"),
|
||||
content: page,
|
||||
});
|
||||
// 역할 배지는 제목 줄 오른쪽에 둔다(자리는 CSS 격자가 잡는다).
|
||||
const tag = createTag(
|
||||
roleLabel(state.user.role),
|
||||
state.user.role === "SYSTEM_ADMIN" ? "accent" : "neutral",
|
||||
);
|
||||
tag.classList.add("b01-dashboard__role");
|
||||
header.append(text, tag);
|
||||
return header;
|
||||
layout.root.querySelector(".ui-general-layout__header")?.append(tag);
|
||||
return layout.root;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,40 @@
|
||||
import { isBlank } from "@util/common_util_validate";
|
||||
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";
|
||||
|
||||
const PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
export function buildProfileForm(user: DashboardUser): HTMLElement {
|
||||
const fields = buildUserFields(user);
|
||||
// 로그인한 본인의 정보다 — 이메일 라벨도 「이메일」로 나간다 (2026-09-06 사용자 지시).
|
||||
const fields = buildUserFields(user, { self: true });
|
||||
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({
|
||||
label: L("B01_Dashboard_SaveProfile"),
|
||||
onClick: async function onB01_Profile_Save_Click() {
|
||||
@@ -16,7 +43,7 @@ export function buildProfileForm(user: DashboardUser): HTMLElement {
|
||||
},
|
||||
});
|
||||
const wrap = document.createElement("div");
|
||||
wrap.append(grid, save);
|
||||
wrap.append(grid, signatureSlot, save);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser
|
||||
[
|
||||
L("B01_Dashboard_Table_Project"),
|
||||
L("B01_Dashboard_Table_Region"),
|
||||
L("B01_Dashboard_Table_Progress"),
|
||||
// 진행도(%) 열은 없앴다 (2026-09-06 사용자 지시) — 워크플로 배지가 같은 것을 보여 준다.
|
||||
L("B01_Dashboard_Table_Workflow"),
|
||||
L("B01_Dashboard_Table_Updated"),
|
||||
L("B01_Dashboard_Table_Action"),
|
||||
@@ -44,7 +44,6 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser
|
||||
return [
|
||||
text(project.name),
|
||||
text(project.region),
|
||||
text(`${project.progress_percent}%`),
|
||||
workflow(project),
|
||||
text(formatDate(project.updated_at)),
|
||||
actCell,
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
.b01-dashboard {
|
||||
max-width: var(--page-max-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-40) var(--spacing-24) var(--spacing-64);
|
||||
/* 폭·제목·여백은 공용 템플릿(ui_template_general_layout)이 잡는다 (2026-09-06 사용자 지시).
|
||||
여기서는 역할 배지를 제목 줄 오른쪽에 세우는 것만 한다. */
|
||||
.b01-dashboard .ui-general-layout__header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.b01-dashboard__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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 .ui-general-layout__title,
|
||||
.b01-dashboard .ui-general-layout__subtitle {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.b01-dashboard__role {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -46,6 +37,32 @@
|
||||
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 {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
@@ -9,6 +9,7 @@ from uuid import uuid4
|
||||
|
||||
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_storage import PROJECT_STORAGE_LAYOUT_V2
|
||||
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(
|
||||
*,
|
||||
request: Any | None = None,
|
||||
user_id: int,
|
||||
company_id: int,
|
||||
name: str,
|
||||
@@ -110,10 +112,13 @@ async def create_project(
|
||||
# 워크플로우 단계별 상태 초기화 시드
|
||||
await initialize_project_stages(cursor, project_id)
|
||||
|
||||
await cursor.execute(
|
||||
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id)
|
||||
VALUES (%s, 'PROJECT_CREATE', 'project', NULL)""",
|
||||
(user_id,),
|
||||
await record_audit(
|
||||
cursor,
|
||||
actor_id=user_id,
|
||||
action="PROJECT_CREATE",
|
||||
resource_type="project",
|
||||
resource_ref=project_id,
|
||||
request=request,
|
||||
)
|
||||
_initialize_project_storage(project_root, project_id)
|
||||
await connection.commit()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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 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)
|
||||
async def post_project(
|
||||
request: Request,
|
||||
payload: CreateProjectRequest,
|
||||
session: dict[str, Any] = Depends(require_company),
|
||||
) -> CreateProjectResponse:
|
||||
@@ -50,6 +51,7 @@ async def post_project(
|
||||
|
||||
try:
|
||||
result = await create_project(
|
||||
request=request,
|
||||
user_id=int(session["user_id"]),
|
||||
company_id=int(company_id),
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -19,14 +19,10 @@ import {
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { isBlank } from "@util/common_util_validate";
|
||||
import {
|
||||
fetchCompanyAssets,
|
||||
fetchCompanyMembers,
|
||||
fetchDashboardMe,
|
||||
fetchUserCompany,
|
||||
} from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
||||
import { createAssetField } from "../B01_Dashboard/B01_Dashboard_UI_AssetPicker";
|
||||
import { fetchCompanyMembers, fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
||||
import { createNameComposer, type NameParts } from "./B02_ProjRegister_UI_Name";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
|
||||
import "./B02_ProjRegister_UI_Style.css";
|
||||
@@ -40,29 +36,15 @@ function L(key: keyof typeof ui_locales): string {
|
||||
* 페이지 진입점
|
||||
* -------------------------------------------------------------------------- */
|
||||
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({
|
||||
label: "프로젝트명 (직접 입력 부분)",
|
||||
placeholder: "예: 가리왕산지구",
|
||||
label: "프로젝트명",
|
||||
placeholder: "예: 2026 가리왕산 간선임도 1구간",
|
||||
required: true,
|
||||
});
|
||||
const namePreview = document.createElement("p");
|
||||
namePreview.className = "b02-proj__preview";
|
||||
const regionField = createInputField({
|
||||
label: L("B02_Proj_Field_Region"),
|
||||
placeholder: L("B02_Proj_Field_Region_Placeholder"),
|
||||
@@ -120,19 +102,12 @@ export function renderB02ProjRegister(root: HTMLElement): void {
|
||||
const fieldLeadField = person("분야별책임자 (도면 표제란)");
|
||||
const designerField = person("설계자 (도면 표제란)");
|
||||
const personSelects = [pmField.select, fieldLeadField.select, designerField.select];
|
||||
// 로고 칸은 회사 자산을 받아야 세울 수 있어 자리만 먼저 잡는다.
|
||||
const logoSlot = document.createElement("div");
|
||||
let logoValue: () => number | null = () => null;
|
||||
// 로고 칸은 두지 않는다 (2026-09-06 사용자 지시) — 프로젝트가 이미 회사에 매여 있어
|
||||
// 도면은 회사 로고를 그대로 쓴다(표제란 조회가 `COALESCE(프로젝트, 회사)`).
|
||||
|
||||
void (async () => {
|
||||
const [me, company] = await Promise.all([
|
||||
fetchDashboardMe(),
|
||||
fetchUserCompany().catch(() => null),
|
||||
]);
|
||||
const [members, assets] = await Promise.all([
|
||||
fetchCompanyMembers().catch(() => []),
|
||||
fetchCompanyAssets().catch(() => []),
|
||||
]);
|
||||
const me = await fetchDashboardMe();
|
||||
const members = await fetchCompanyMembers().catch(() => []);
|
||||
const memberText = (member: (typeof members)[number]) =>
|
||||
member.position ? `${member.name} (${member.position})` : member.name;
|
||||
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 (company) {
|
||||
// 회사 대표 로고가 기본값 — 프로젝트마다 다른 로고를 쓰면 여기서 바꾼다.
|
||||
const logo = createAssetField(
|
||||
"회사 로고 (도면 표제란)",
|
||||
"LOGO",
|
||||
assets,
|
||||
company.logo_asset_id ?? null,
|
||||
company.id,
|
||||
me,
|
||||
);
|
||||
logoValue = logo.value;
|
||||
logoSlot.append(logo.root);
|
||||
}
|
||||
})();
|
||||
|
||||
const roadTypeText = (): string =>
|
||||
roadTypeField.select.options[roadTypeField.select.selectedIndex]?.textContent ?? "";
|
||||
const composedName = (): string =>
|
||||
[
|
||||
yearField.input.value.trim(),
|
||||
regionField.input.value.trim(),
|
||||
roadTypeText(),
|
||||
nameField.input.value.trim(),
|
||||
]
|
||||
.filter((part) => part.length > 0)
|
||||
.join(" ");
|
||||
/** 이름 조합기 — 글자마다 주인을 기억해 사용자가 사이에 글을 넣어도 연동이 안 끊긴다. */
|
||||
const nameComposer = createNameComposer();
|
||||
const nameParts = (): NameParts => ({
|
||||
year: yearField.input.value.trim(),
|
||||
region: regionField.input.value.trim(),
|
||||
type: roadTypeText(),
|
||||
});
|
||||
nameField.input.addEventListener("input", () => {
|
||||
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 start = isBlank(routeStartField.input.value)
|
||||
? null
|
||||
@@ -185,14 +152,15 @@ export function renderB02ProjRegister(root: HTMLElement): void {
|
||||
return end > from ? end - from : null;
|
||||
};
|
||||
const refresh = (): void => {
|
||||
namePreview.textContent = `저장될 이름: ${composedName() || "(입력 대기)"}`;
|
||||
syncName();
|
||||
const length = routeLength();
|
||||
routeEndField.root.querySelector(".ui-field__label")!.textContent =
|
||||
length === null
|
||||
? "노선 종료 누가거리 (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);
|
||||
}
|
||||
roadTypeField.select.addEventListener("change", refresh);
|
||||
@@ -203,7 +171,6 @@ export function renderB02ProjRegister(root: HTMLElement): void {
|
||||
variant: "filled",
|
||||
onClick: onB02_Proj_Submit_Click,
|
||||
});
|
||||
submitBtn.classList.add("b02-proj__submit");
|
||||
const cancelBtn = createButton({
|
||||
label: L("Common_Btn_Cancel"),
|
||||
variant: "ghost",
|
||||
@@ -284,7 +251,6 @@ export function renderB02ProjRegister(root: HTMLElement): void {
|
||||
pm_user_id: idOrNull(pmField.select),
|
||||
field_lead_user_id: idOrNull(fieldLeadField.select),
|
||||
designer_user_id: idOrNull(designerField.select),
|
||||
logo_asset_id: logoValue(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -318,22 +284,28 @@ export function renderB02ProjRegister(root: HTMLElement): void {
|
||||
regionField.root,
|
||||
roadTypeField.root,
|
||||
nameField.root,
|
||||
namePreview,
|
||||
routeStartField.root,
|
||||
routeEndField.root,
|
||||
memoField.root,
|
||||
clientOrgField.root,
|
||||
designDateField.root,
|
||||
pmField.root,
|
||||
fieldLeadField.root,
|
||||
designerField.root,
|
||||
logoSlot,
|
||||
// 비고는 맨 끝 (2026-09-06 사용자 지시).
|
||||
memoField.root,
|
||||
);
|
||||
|
||||
// 제목·버튼 줄은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — 다른 화면과 같은
|
||||
// 제목 크기·여백을 쓰고, 버튼 줄은 `ui-general-block__actions` 로 세운다.
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b02-proj__actions";
|
||||
actions.className = "ui-general-block__actions b02-proj__actions";
|
||||
actions.append(cancelBtn, submitBtn);
|
||||
const card = createCard({ body: [grid, actions], raised: true });
|
||||
page.append(header, card);
|
||||
root.append(page);
|
||||
const layout = createGeneralLayout({
|
||||
pageClass: "b02-proj",
|
||||
title: L("B02_Proj_Title"),
|
||||
subtitle: L("B02_Proj_Subtitle"),
|
||||
content: card,
|
||||
});
|
||||
root.append(layout.root);
|
||||
}
|
||||
|
||||
@@ -3,30 +3,13 @@
|
||||
* 프로젝트 등록 페이지 전용 스타일 (theme.css 변수만 사용)
|
||||
* ========================================================================== */
|
||||
|
||||
.b02-proj {
|
||||
/* 제목·여백·버튼 줄은 공용 템플릿(ui_template_general_layout)을 쓴다 — 여기서는
|
||||
이 화면에만 필요한 것(폭·2열 그리드·셀렉트 화살표·버튼 오른쪽 정렬)만 둔다. */
|
||||
|
||||
/* 입력 칸 두 줄짜리 폼이라 종전처럼 720px 로 좁혀 가운데 둔다 (2026-09-06 사용자 지시 —
|
||||
템플릿을 쓰되 폭은 바꾸지 않는다). */
|
||||
.b02-proj .ui-general-layout__inner {
|
||||
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열) */
|
||||
@@ -53,27 +36,14 @@
|
||||
padding-right: var(--spacing-32);
|
||||
}
|
||||
|
||||
.b02-proj__submit {
|
||||
align-self: flex-start;
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.b02-proj__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 조합된 프로젝트명 미리보기 — 저장될 이름 그대로 보인다 (2026-09-06). */
|
||||
.b02-proj__preview {
|
||||
margin: calc(-1 * var(--spacing-8)) 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 취소·등록 버튼 줄 */
|
||||
/* 취소·등록 버튼 줄 — 나머지는 공용 `.ui-general-block__actions` 가 맡는다. */
|
||||
.b02-proj__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--spacing-16);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,9 @@ from B03_FileInput.B03_FileInput_Router_Helpers import (
|
||||
from B03_FileInput.B03_FileInput_Router_Helpers import (
|
||||
_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 (
|
||||
FileUploadDescriptor,
|
||||
FileUploadResponse,
|
||||
@@ -156,12 +159,13 @@ async def upload_project_files(
|
||||
"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(
|
||||
status_code=400,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.",
|
||||
"message": "LAS 또는 LAZ 파일을 1개 이상 포함해야 합니다.",
|
||||
},
|
||||
)
|
||||
# 계획노선은 shapefile 또는 CSV 한 벌이다 (2026-08-31) — 문구도 그렇게 맞춘다
|
||||
@@ -175,7 +179,7 @@ async def upload_project_files(
|
||||
"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)
|
||||
if missing_required:
|
||||
return JSONResponse(
|
||||
@@ -350,8 +354,10 @@ async def get_project_upload_overview(
|
||||
)
|
||||
for row in sessions
|
||||
],
|
||||
required_complete=_REQUIRED_FILE_TYPES <= file_types
|
||||
and (point_cloud_id is not None or stage0_complete),
|
||||
# stage 0 을 마쳤으면 서버 필수검사를 이미 통과한 것이다 — LAS 없이 설계는
|
||||
# 지형 한 벌(prj·tfw)이 아예 없으므로 여기서 다시 세면 B04 이동이 막힌다.
|
||||
required_complete=stage0_complete
|
||||
or (_REQUIRED_FILE_TYPES <= file_types and point_cloud_id is not None),
|
||||
analysis_complete=analysis_complete,
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -62,14 +62,34 @@ def _is_point_cloud_result(result: UploadedFileResult) -> bool:
|
||||
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]:
|
||||
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):
|
||||
missing.append("csv/shp")
|
||||
# shapefile로 왔으면 형제 파일이 다 있어야 노선을 읽는다.
|
||||
if "shp" in 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):
|
||||
missing.append("las/laz")
|
||||
return missing
|
||||
@@ -143,6 +163,15 @@ async def _complete_file_input_if_ready(
|
||||
# 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다.
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
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)
|
||||
discard_initial_snapshot(project_root)
|
||||
await purge_project_outputs(connection, str(project_id), project_root)
|
||||
|
||||
@@ -79,11 +79,18 @@ async def trigger_wf1_analysis_and_email(
|
||||
await connection.commit()
|
||||
stored_path = await get_project_storage_relative_path(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)
|
||||
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"]))
|
||||
# LAS 없는 설계(2026-08-30): 입력이 계획노선 CSV면 도엽등고선 서피스 분석으로 간다.
|
||||
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(
|
||||
run_surface_analysis,
|
||||
project_root,
|
||||
source_path,
|
||||
terrain_paths or [source_path],
|
||||
source_filters=None,
|
||||
methods=methods,
|
||||
force=False,
|
||||
|
||||
@@ -24,15 +24,11 @@ import { createUploadFlow } from "./B03_FileInput_UI_Page_Flow";
|
||||
import {
|
||||
isSlotRequired,
|
||||
slotForOverviewFile,
|
||||
terrainCoverage,
|
||||
validateFileForSlot,
|
||||
validateSlots,
|
||||
} from "./B03_FileInput_UI_Page_Rules";
|
||||
import {
|
||||
readCrsLabel,
|
||||
readExtent,
|
||||
renderSlotPreview,
|
||||
type PreviewExtent,
|
||||
} from "./B03_FileInput_UI_Preview";
|
||||
import { readCrsLabel, renderSlotPreview } from "./B03_FileInput_UI_Preview";
|
||||
import { confirmReplaceUpload } from "./B03_FileInput_UI_Upload";
|
||||
import {
|
||||
createFileCardTemplate,
|
||||
@@ -41,9 +37,11 @@ import {
|
||||
initializeSlots,
|
||||
makeSessionKey,
|
||||
planSlotAssignments,
|
||||
pushExtraFile,
|
||||
ROUTE_SLOTS,
|
||||
SHAPEFILE_DEPENDENT_SLOTS,
|
||||
slotConfigs,
|
||||
slotFileLabel,
|
||||
TERRAIN_SLOTS,
|
||||
type FileSlot,
|
||||
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;
|
||||
// 로컬 파일이 없어도 서버에 업로드된 파일이 있으면 그 정보(정본)를 보여준다.
|
||||
if (fileName) fileName.textContent = state.file?.name ?? state.serverUploaded?.name ?? "";
|
||||
if (fileName) fileName.textContent = slotFileLabel(state);
|
||||
if (fileSize) {
|
||||
fileSize.textContent = state.file
|
||||
? formatBytes(state.file.size)
|
||||
@@ -240,7 +238,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
|
||||
const preview = card.querySelector<HTMLDivElement>(".b03-file__preview");
|
||||
if (preview) {
|
||||
const terrain = terrainCoverage();
|
||||
const terrain = terrainCoverage(slots);
|
||||
renderSlotPreview(preview, {
|
||||
metadata: state.serverUploaded?.metadata,
|
||||
// 업로드·분석이 끝난 카드에만 보인다 (2026-09-04 사용자 지시).
|
||||
@@ -258,26 +256,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
* 들어오는지 대조하는 기준. 초기 계산 실패의 주된 원인이 범위 불일치라
|
||||
* 카드에서 바로 보이게 한다(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 {
|
||||
const state = slots.get(slot);
|
||||
if (!state) return;
|
||||
@@ -297,8 +275,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
showErrorMessage(state.slot, `${validation} ${file.name}`);
|
||||
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;
|
||||
}
|
||||
// 서버에 이미 완료된 슬롯이면 교체 확인을 받는다(2026-08-04 사용자 지시). 이어올리기로
|
||||
@@ -382,6 +365,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
localStorage.removeItem(makeSessionKey(activeProjectId, state.file));
|
||||
}
|
||||
state.file = undefined;
|
||||
state.extraFiles = undefined;
|
||||
state.uploadSessionId = undefined;
|
||||
state.uploadStatus = "pending";
|
||||
state.progressBytes = 0;
|
||||
|
||||
@@ -201,14 +201,24 @@ export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle {
|
||||
ctx.pageError.textContent = "";
|
||||
ctx.setUploading(true);
|
||||
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(
|
||||
ctx.projectId(),
|
||||
state,
|
||||
index === targetStates.length - 1,
|
||||
index === jobs.length - 1,
|
||||
() => ctx.renderSlot(state.slot),
|
||||
ctx.lasFreeDesign(),
|
||||
file,
|
||||
);
|
||||
}
|
||||
clearDerivedCaches(ctx.projectId());
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { UPLOAD_MAX_FILES, UPLOAD_MAX_MB } from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import type { UploadOverviewFile } from "./B03_FileInput_Api_Fetch";
|
||||
import { readCrsLabel, readExtent, type PreviewExtent } from "./B03_FileInput_UI_Preview";
|
||||
import {
|
||||
getExtension,
|
||||
SHAPEFILE_DEPENDENT_SLOTS,
|
||||
@@ -105,3 +106,31 @@ export function slotForOverviewFile(
|
||||
(candidate) => candidate.slot !== "route_prj" && candidate.extensions.includes(extension),
|
||||
)?.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 };
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ export interface SlotConfig {
|
||||
|
||||
export interface FileSlotState extends SlotConfig {
|
||||
file?: File;
|
||||
/**
|
||||
* 같은 카드에 더 담은 파일 — 지형 자료(포인트클라우드)만 여러 장을 받는다.
|
||||
* 드론 라이다는 사업지가 넓으면 도엽별로 나뉘어 오고, 전처리가 합쳐서 쓴다
|
||||
* (2026-09-06 사용자 확정). 업로드는 이 목록을 한 장씩 차례로 올린다.
|
||||
*/
|
||||
extraFiles?: File[];
|
||||
uploadSessionId?: string;
|
||||
uploadStatus: UploadStatus;
|
||||
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 {
|
||||
const index = fileName.lastIndexOf(".");
|
||||
return index >= 0 ? fileName.slice(index).toLowerCase() : "";
|
||||
|
||||
@@ -83,8 +83,10 @@ export async function uploadOneFile(
|
||||
completeUpload: boolean,
|
||||
onProgress: () => void,
|
||||
lasFree = false,
|
||||
// 지형 자료는 한 카드에 여러 장이 담긴다 — 올릴 파일을 지정받는다(2026-09-06).
|
||||
target?: File,
|
||||
): Promise<UploadedFileResult[]> {
|
||||
const file = state.file;
|
||||
const file = target ?? state.file;
|
||||
if (!file) return [];
|
||||
state.error = undefined;
|
||||
state.uploadStatus = "uploading";
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -37,14 +37,22 @@ GROUND_POINT_SAMPLE_LIMIT = 500_000
|
||||
GROUND_POINT_CACHE_VERSION = 2
|
||||
|
||||
|
||||
def _source_identity(las_path: Path) -> dict[str, Any]:
|
||||
"""입력 LAS의 정체성(이름·크기·수정시각)으로 캐시 세대를 식별한다 (PLAN B-2)."""
|
||||
stat = las_path.stat()
|
||||
return {
|
||||
"filename": las_path.name,
|
||||
"size_bytes": int(stat.st_size),
|
||||
"mtime": float(stat.st_mtime),
|
||||
}
|
||||
def _source_identity(las_paths: list[Path]) -> dict[str, Any]:
|
||||
"""입력 지형 파일들의 정체성(이름·크기·수정시각)으로 캐시 세대를 식별한다 (PLAN B-2).
|
||||
|
||||
여러 장을 병합하므로 **한 장이라도 바뀌거나 늘고 줄면** 다시 계산해야 한다
|
||||
(2026-09-06 다중 입력).
|
||||
"""
|
||||
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:
|
||||
@@ -108,7 +116,7 @@ def cache_ground_points(
|
||||
|
||||
def run_surface_analysis(
|
||||
project_root: Path,
|
||||
las_path: Path,
|
||||
las_path: Path | Sequence[Path],
|
||||
*,
|
||||
source_filters: list[str] | None,
|
||||
methods: list[str],
|
||||
@@ -117,6 +125,9 @@ def run_surface_analysis(
|
||||
) -> dict[str, Any]:
|
||||
"""구조화→필터→모델 빌드를 수행하고 산출 메타데이터를 반환한다.
|
||||
|
||||
`las_path`는 지형 파일 한 장 또는 여러 장이다 — 여러 장이면 합친 범위로 한 벌을
|
||||
만든다(2026-09-06 사용자 확정).
|
||||
|
||||
`source_filters`가 비면 입력 LAS를 보고 기본 필터를 정한다(자동 전처리 경로).
|
||||
|
||||
반환 dict:
|
||||
@@ -132,6 +143,9 @@ def run_surface_analysis(
|
||||
on_progress(percent, stage, message)
|
||||
|
||||
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"
|
||||
processed_dir = stage_root / "processed"
|
||||
models_dir = stage_root / "models"
|
||||
@@ -140,7 +154,7 @@ def run_surface_analysis(
|
||||
|
||||
# 0. 입력 세대 검증: LAS가 바뀌었으면 모든 캐시를 재계산한다 (PLAN B-2)
|
||||
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
|
||||
if identity_path.is_file():
|
||||
try:
|
||||
@@ -154,10 +168,12 @@ def run_surface_analysis(
|
||||
if rebuild or not structured_path.is_file():
|
||||
_report(10, "structurize", "LAS 구조화 중")
|
||||
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)
|
||||
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:
|
||||
_report(10, "structurize", "구조화 캐시 재사용")
|
||||
@@ -254,7 +270,7 @@ def run_surface_analysis(
|
||||
"z": [float(bounds[2, 0]), float(bounds[2, 1])],
|
||||
}
|
||||
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가 있어도 참고용으로 같이 만들어 영구저장한다
|
||||
|
||||
@@ -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 tempfile
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import laspy
|
||||
import numpy as np
|
||||
|
||||
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(
|
||||
las_path: str | Path,
|
||||
las_path: PathLike | Sequence[PathLike],
|
||||
output_dir: str | Path,
|
||||
progress_callback: Callable[[int], None] | None = None,
|
||||
progress_callback: ProgressCallback | None = None,
|
||||
) -> Path:
|
||||
"""LAS/LAZ 속성을 청크로 읽어 B04 structured.npz로 원자적 저장한다."""
|
||||
source = Path(las_path)
|
||||
"""지형 파일 한 장 또는 여러 장을 청크로 읽어 B04 structured.npz로 원자적 저장한다."""
|
||||
sources = _as_list(las_path)
|
||||
if not sources:
|
||||
raise ValueError("구조화할 지형 파일이 없습니다.")
|
||||
target_dir = Path(output_dir)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = target_dir / "structured.npz"
|
||||
|
||||
with laspy.open(source) as las_file:
|
||||
header = las_file.header
|
||||
total_points = int(header.point_count)
|
||||
point_format = header.point_format
|
||||
dimensions = set(point_format.dimension_names)
|
||||
has_rgb = {"red", "green", "blue"}.issubset(dimensions)
|
||||
has_intensity = "intensity" in dimensions
|
||||
has_returns = {"return_number", "number_of_returns"}.issubset(dimensions)
|
||||
has_classification = "classification" in dimensions
|
||||
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)
|
||||
total_points, bounds, has_rgb = _headers(sources)
|
||||
thin = total_points > SURFACE_THIN_TRIGGER_POINTS
|
||||
merged = _merge_sources(sources, total_points, bounds, thin, progress_callback)
|
||||
logger.info(
|
||||
"B04 구조화: 파일 %d장 원본 %d점 → 저장 %d점 (씨닝 %s)",
|
||||
len(sources),
|
||||
total_points,
|
||||
merged.size,
|
||||
f"{SURFACE_THIN_CELL_SIZE_M}m 칸" if thin else "없음",
|
||||
)
|
||||
|
||||
temporary_path: Path | None = None
|
||||
try:
|
||||
@@ -90,14 +316,12 @@ def structurize_las(
|
||||
temporary_path = Path(temporary.name)
|
||||
np.savez_compressed(
|
||||
temporary,
|
||||
xyz=xyz,
|
||||
intensity=intensity,
|
||||
rgb=rgb,
|
||||
return_number=return_number,
|
||||
number_of_returns=number_of_returns,
|
||||
classification=classification,
|
||||
**merged.arrays(),
|
||||
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),
|
||||
)
|
||||
temporary.flush()
|
||||
@@ -109,6 +333,6 @@ def structurize_las(
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
if progress_callback and total_points == 0:
|
||||
if progress_callback:
|
||||
progress_callback(100)
|
||||
return target
|
||||
|
||||
@@ -6,7 +6,7 @@ terrain_layers(지형 레이어) 테이블에 메타데이터와 상대 경로
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import PurePosixPath
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
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(
|
||||
connection: aiomysql.Connection, project_id: UUID
|
||||
) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -26,6 +26,7 @@ from B04_PreProcess.B04_PreProcess_Repository import (
|
||||
clear_confirmed_surface_models,
|
||||
get_input_file,
|
||||
list_project_point_cloud_inputs,
|
||||
list_project_point_cloud_paths,
|
||||
list_surface_models,
|
||||
save_surface_analysis_to_db,
|
||||
)
|
||||
@@ -117,6 +118,10 @@ async def analyze_surface(
|
||||
status_code=404,
|
||||
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 분석을 시작합니다.")
|
||||
@@ -128,7 +133,7 @@ async def analyze_surface(
|
||||
result = await asyncio.to_thread(
|
||||
run_surface_analysis,
|
||||
project_root,
|
||||
las_path,
|
||||
terrain_paths or [las_path],
|
||||
source_filters=source_filters,
|
||||
methods=methods,
|
||||
force=request.force,
|
||||
|
||||
@@ -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
|
||||
@@ -8,14 +8,16 @@
|
||||
|
||||
import logging
|
||||
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 config.config_db import get_db_pool
|
||||
|
||||
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와 영구저장소에서 완전히 지운다. 되돌릴 수 없다.
|
||||
|
||||
자식 테이블은 나열하지 않는다 — `projects.id`를 참조하는 테이블이 전부
|
||||
@@ -60,10 +62,13 @@ async def hard_delete_project(project_id: str, actor_id: int) -> bool:
|
||||
await connection.rollback()
|
||||
return False
|
||||
# 감사 기록은 프로젝트가 사라진 뒤에도 남는다. resource_id는 FK가 없어 고아가 되지 않는다.
|
||||
await cursor.execute(
|
||||
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id)
|
||||
VALUES (%s, 'PROJECT_HARD_DELETE', 'project', NULL)""",
|
||||
(actor_id,),
|
||||
await record_audit(
|
||||
cursor,
|
||||
actor_id=actor_id,
|
||||
action="PROJECT_HARD_DELETE",
|
||||
resource_type="project",
|
||||
resource_ref=project_id,
|
||||
request=request,
|
||||
)
|
||||
await connection.commit()
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from B03_FileInput.B03_FileInput_Repository_Temp import (
|
||||
delete_temp_batch,
|
||||
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 config.config_db import get_db_pool
|
||||
from config.config_system import (
|
||||
@@ -81,4 +82,6 @@ async def cleanup_expired_temp_uploads_loop() -> None:
|
||||
removed = await cleanup_expired_temp_uploads()
|
||||
if removed:
|
||||
logger.info("임시 보관함 정리 완료: %d건 삭제", removed)
|
||||
# 시스템 로그 보관 기간 정리도 같은 주기에 얹는다 — 루프를 따로 두지 않는다.
|
||||
await purge_expired_audit_logs()
|
||||
await asyncio.sleep(interval_seconds)
|
||||
|
||||
@@ -98,6 +98,8 @@ CHUNK_RETENTION_HOURS = int(os.getenv("CHUNK_RETENTION_HOURS", "24"))
|
||||
# 운영하며 조정할 값이라 여기서 관리한다(2026-08-08 사용자 지시).
|
||||
TEMP_UPLOAD_DIR_NAME = os.getenv("TEMP_UPLOAD_DIR_NAME", "tmp")
|
||||
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"))
|
||||
MERGE_TIMEOUT_SECONDS = int(os.getenv("MERGE_TIMEOUT_SECONDS", "3600"))
|
||||
SEND_ANALYSIS_COMPLETION_EMAIL = (
|
||||
|
||||
@@ -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_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"))
|
||||
|
||||
# 지형 파일 여러 장 병합 (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"))
|
||||
|
||||
# CSF (Cloth Simulation Filter) 지면 분류 파라미터
|
||||
|
||||
@@ -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 인덱스를 그대로 쓴다.
|
||||
@@ -125,7 +125,6 @@ export const ui_locales_b1 = {
|
||||
B01_Dashboard_SaveProfile: ["기본정보 저장", "Save profile"],
|
||||
B01_Dashboard_Table_Project: ["프로젝트명", "Project"],
|
||||
B01_Dashboard_Table_Region: ["지역", "Region"],
|
||||
B01_Dashboard_Table_Progress: ["진행도", "Progress"],
|
||||
B01_Dashboard_Table_Workflow: ["워크플로우", "Workflow"],
|
||||
B01_Dashboard_Table_Updated: ["수정일", "Updated"],
|
||||
B01_Dashboard_Table_Email: ["이메일", "Email"],
|
||||
@@ -137,12 +136,17 @@ export const ui_locales_b1 = {
|
||||
B01_Dashboard_Table_Company: ["회사명", "Company"],
|
||||
B01_Dashboard_Table_Requested: ["신청일", "Requested"],
|
||||
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_Field_BusinessNumber: ["사업자등록번호", "Business number"],
|
||||
B01_Dashboard_Field_Address: ["주소", "Address"],
|
||||
B01_Dashboard_Field_Owner: ["대표자명", "Owner"],
|
||||
B01_Dashboard_Field_Search: ["검색어", "Search"],
|
||||
B01_Dashboard_Field_MemberEmail: ["팀원 이메일", "Member email"],
|
||||
B01_Dashboard_Field_Email: ["이메일", "Email"],
|
||||
B01_Dashboard_Metric_Cpu: ["CPU", "CPU"],
|
||||
B01_Dashboard_Metric_Memory: ["메모리", "Memory"],
|
||||
B01_Dashboard_Metric_Disk: ["디스크", "Disk"],
|
||||
|
||||
Reference in New Issue
Block a user