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

This commit is contained in:
2026-09-02 19:25:15 +09:00
6 changed files with 181 additions and 25 deletions
@@ -10,11 +10,13 @@ import secrets
from typing import Any
import aiomysql
from fastapi import HTTPException
from common_util.common_util_auth import hash_password
from config.config_db import get_db_pool
from .B01_Dashboard_Repository import _role, get_dashboard_me
from .B01_Dashboard_Repository_Assets import list_company_assets
async def list_company_members(company_id: int) -> list[dict[str, Any]]:
@@ -109,3 +111,24 @@ async def set_company_logo(company_id: int, asset_id: int | None) -> bool:
changed = cursor.rowcount > 0
await connection.commit()
return changed
async def check_project_refs(company_id: int, data: dict[str, Any]) -> None:
"""담당자·로고·서명은 그 회사의 것만 물린다 (2026-09-02 사용자 확정).
프로젝트 수정(B01)과 등록(B02)이 같은 규칙을 써야 해서 저장소에 둔다.
"""
user_ids = {
data.get(k) for k in ("pm_user_id", "field_lead_user_id", "designer_user_id") if data.get(k)
}
if user_ids and not user_ids <= {m["id"] for m in await list_company_members(company_id)}:
raise HTTPException(status_code=400, detail="담당자는 같은 회사 구성원이어야 합니다.")
wanted = {
k: kind
for k, kind in (("logo_asset_id", "LOGO"), ("signature_asset_id", "SIGNATURE"))
if data.get(k)
}
if wanted:
kinds = {a["id"]: a["kind"] for a in await list_company_assets(company_id)}
if any(kinds.get(data[k]) != kind for k, kind in wanted.items()):
raise HTTPException(status_code=400, detail="로고·서명은 같은 회사 자산이어야 합니다.")
+6 -22
View File
@@ -6,7 +6,7 @@ from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile
from common_util.common_util_auth import require_system_admin, verify_session
from common_util.common_util_auth import require_company, require_system_admin, verify_session
from common_util.common_util_project_delete import hard_delete_project
from common_util.common_util_storage import read_stored_asset
from config.config_system import PROJECT_DELETE_HARD_ENABLED
@@ -48,6 +48,7 @@ from .B01_Dashboard_Repository_Assets import (
)
from .B01_Dashboard_Repository_Members import (
add_company_member,
check_project_refs,
list_company_members,
remove_company_member,
set_company_logo,
@@ -106,25 +107,6 @@ async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, An
return asset
async def _check_project_refs(project: dict[str, Any], data: dict[str, Any]) -> None:
"""담당자·로고·서명은 프로젝트 회사의 것만 물린다 (2026-09-02 사용자 확정)."""
company_id = int(project["company_id"])
user_ids = {
data[k] for k in ("pm_user_id", "field_lead_user_id", "designer_user_id") if data[k]
}
if user_ids and not user_ids <= {m["id"] for m in await list_company_members(company_id)}:
raise HTTPException(status_code=400, detail="담당자는 같은 회사 구성원이어야 합니다.")
wanted = {
k: kind
for k, kind in (("logo_asset_id", "LOGO"), ("signature_asset_id", "SIGNATURE"))
if data[k]
}
if wanted:
kinds = {a["id"]: a["kind"] for a in await list_company_assets(company_id)}
if any(kinds.get(data[k]) != kind for k, kind in wanted.items()):
raise HTTPException(status_code=400, detail="로고·서명은 같은 회사 자산이어야 합니다.")
def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool:
if session["role"] == "SYSTEM_ADMIN":
return True
@@ -200,8 +182,10 @@ async def user_company_join(
@router.get("/admin/members")
async def admin_members(
company_id: int | None = Query(None, gt=0),
session: dict[str, Any] = Depends(require_company_admin),
session: dict[str, Any] = Depends(require_company),
):
# 읽기는 회사 구성원 누구나 — B02 등록 화면에서 일반 사용자도 담당자를 골라야 한다
# (2026-09-02 사용자 지시). 쓰기(POST·DELETE)는 관리자 그대로다.
# company_id 는 시스템관리자가 남의 회사 프로젝트 담당자를 고를 때만 쓴다.
return {
"status": "success",
@@ -280,7 +264,7 @@ async def dashboard_update_project(
if not _can_edit_project(session, project):
raise HTTPException(status_code=403, detail="프로젝트 수정 권한이 없습니다.")
data = payload.model_dump()
await _check_project_refs(project, data)
await check_project_refs(int(project["company_id"]), data)
if not await update_project(project_id, data, int(session["user_id"])):
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
return {"status": "success"}
@@ -51,10 +51,12 @@ async def create_project(
project_year: int,
estimated_length_m: float | None,
memo: str | None,
title_block: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""신규 프로젝트를 DB에 저장하고 워크플로우 저장소를 초기화한다."""
project_id = str(uuid4())
fields = title_block or {}
storage_path, project_root = _build_project_storage(company_id, user_id, project_id)
now = datetime.utcnow()
@@ -67,9 +69,12 @@ async def create_project(
INSERT INTO projects (
id, user_id, company_id, name, region, road_type,
project_year, estimated_length_m, memo, status,
crs_epsg, storage_path, created_at, updated_at
crs_epsg, storage_path, created_at, updated_at,
client_org, project_number, work_amount, design_date,
pm_user_id, field_lead_user_id, designer_user_id, logo_asset_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s)
""",
(
project_id,
@@ -84,6 +89,14 @@ async def create_project(
storage_path,
now,
now,
fields.get("client_org"),
fields.get("project_number"),
fields.get("work_amount"),
fields.get("design_date"),
fields.get("pm_user_id"),
fields.get("field_lead_user_id"),
fields.get("designer_user_id"),
fields.get("logo_asset_id"),
),
)
# 워크플로우 단계별 상태 초기화 시드
@@ -4,6 +4,7 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from B01_Dashboard.B01_Dashboard_Repository_Members import check_project_refs
from common_util.common_util_auth import require_company
from .B02_ProjRegister_Repository import create_project
@@ -23,6 +24,21 @@ async def post_project(
if company_id is None:
raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.")
title_block = payload.model_dump(
include={
"client_org",
"project_number",
"work_amount",
"design_date",
"pm_user_id",
"field_lead_user_id",
"designer_user_id",
"logo_asset_id",
}
)
# 담당자·로고는 같은 회사 것만 (B01 프로젝트 수정과 같은 규칙).
await check_project_refs(int(company_id), title_block)
try:
result = await create_project(
user_id=int(session["user_id"]),
@@ -33,6 +49,7 @@ async def post_project(
project_year=payload.project_year,
estimated_length_m=payload.estimated_length_m,
memo=payload.memo.strip() if payload.memo else None,
title_block=title_block,
)
return CreateProjectResponse(**result)
except ValueError as exc:
+15 -1
View File
@@ -1,5 +1,7 @@
"""B02_ProjRegister 요청/응답 스키마."""
from datetime import date
from pydantic import BaseModel, Field
@@ -8,10 +10,22 @@ class CreateProjectRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
region: str = Field(..., min_length=1, max_length=100)
road_type: str = Field(..., pattern="^(main|branch|fire|stream)$")
# 화면 선택지와 같은 3종 — 간선·산불진화·작업임도 (지선임도 폐지, 계류보전은 사방사업).
# 옛 값(branch·stream)을 받고 work 를 막고 있어 「작업임도」 등록이 422 로 떨어졌다.
road_type: str = Field(..., pattern="^(main|fire|work)$")
project_year: int = Field(..., ge=2000, le=2100)
estimated_length_m: float | None = Field(default=None, ge=0)
memo: str | None = Field(default=None, max_length=1000)
# 도면 표제란·표지 값 — 등록 때부터 받는다 (2026-09-02 사용자 지시).
# 프로젝트 수정 모달(B01)과 같은 칸이며, 비워 두면 도면에 빈칸으로 나간다.
client_org: str | None = Field(default=None, max_length=255)
project_number: str | None = Field(default=None, max_length=100)
work_amount: str | None = Field(default=None, max_length=100)
design_date: date | None = None
pm_user_id: int | None = Field(default=None, gt=0)
field_lead_user_id: int | None = Field(default=None, gt=0)
designer_user_id: int | None = Field(default=None, gt=0)
logo_asset_id: int | None = Field(default=None, gt=0)
class CreateProjectResponse(BaseModel):
@@ -20,6 +20,14 @@ import {
showToast,
} from "@ui/ui_template_elements";
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 { openAddMemberModal } from "../B01_Dashboard/B01_Dashboard_UI_Modals";
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";
@@ -88,6 +96,84 @@ export function renderB02ProjRegister(root: HTMLElement): void {
type: "text",
});
// 도면 표제란·표지 값 — 프로젝트 수정 모달과 같은 항목을 등록 때부터 받는다
// (2026-09-02 사용자 지시). 비워 두면 도면에 빈칸으로 나간다.
const clientOrgField = createInputField({ label: "시행청 (도면 표제란)" });
const projectNumberField = createInputField({
label: "연도·기번 (표지)",
placeholder: "예: 2026년 간선임도(기번3-울진.대흥)",
});
const workAmountField = createInputField({ label: "사업량 (표지)", placeholder: "예: L=2.14km" });
const designDateField = createInputField({ label: "설계일자 (도면 표제란)", type: "date" });
const NEW_MEMBER = "__new__";
const person = (label: string) =>
createSelectField({ label, options: [{ value: "", text: "(미지정)" }] });
const pmField = person("과업책임자 (도면 표제란)");
const fieldLeadField = person("분야별책임자 (도면 표제란)");
const designerField = person("설계자 (도면 표제란)");
const personSelects = [pmField.select, fieldLeadField.select, designerField.select];
// 로고 칸은 회사 자산을 받아야 세울 수 있어 자리만 먼저 잡는다.
const logoSlot = document.createElement("div");
let logoValue: () => number | null = () => null;
void (async () => {
const [me, company] = await Promise.all([
fetchDashboardMe(),
fetchUserCompany().catch(() => null),
]);
const [members, assets] = await Promise.all([
fetchCompanyMembers().catch(() => []),
fetchCompanyAssets().catch(() => []),
]);
const memberText = (member: (typeof members)[number]) =>
member.position ? `${member.name} (${member.position})` : member.name;
const addOption = (select: HTMLSelectElement, value: string, text: string) => {
const option = document.createElement("option");
option.value = value;
option.textContent = text;
select.append(option);
};
for (const select of personSelects) {
for (const member of members) addOption(select, String(member.id), memberText(member));
// 계정을 만드는 것은 회사 관리자 권한이라 일반 사용자에게는 보이지 않는다.
if (me.role !== "USER") addOption(select, NEW_MEMBER, "+ 신규 등록…");
}
for (const select of personSelects) {
let last = select.value;
select.addEventListener("change", () => {
if (select.value !== NEW_MEMBER) {
last = select.value;
return;
}
select.value = last;
openAddMemberModal((member) => {
for (const other of personSelects) {
const option = document.createElement("option");
option.value = String(member.id);
option.textContent = memberText(member);
other.insertBefore(option, other.options[other.options.length - 1]);
}
select.value = String(member.id);
last = select.value;
});
});
}
if (company) {
// 회사 대표 로고가 기본값 — 프로젝트마다 다른 로고를 쓰면 여기서 바꾼다.
const logo = createAssetField(
"회사 로고 (도면 표제란)",
"LOGO",
assets,
company.logo_asset_id ?? null,
company.id,
me,
);
logoValue = logo.value;
logoSlot.append(logo.root);
}
})();
const submitBtn = createButton({
label: L("B02_Proj_Submit"),
variant: "filled",
@@ -95,6 +181,9 @@ export function renderB02ProjRegister(root: HTMLElement): void {
});
submitBtn.classList.add("b02-proj__submit");
const idOrNull = (select: HTMLSelectElement): number | null =>
select.value && select.value !== NEW_MEMBER ? Number(select.value) : null;
async function onB02_Proj_Submit_Click(): Promise<void> {
nameField.setError();
regionField.setError();
@@ -138,6 +227,14 @@ export function renderB02ProjRegister(root: HTMLElement): void {
project_year: projectYear,
estimated_length_m: estimatedLength,
memo: memoField.input.value.trim() || null,
client_org: clientOrgField.input.value.trim() || null,
project_number: projectNumberField.input.value.trim() || null,
work_amount: workAmountField.input.value.trim() || null,
design_date: designDateField.input.value || null,
pm_user_id: idOrNull(pmField.select),
field_lead_user_id: idOrNull(fieldLeadField.select),
designer_user_id: idOrNull(designerField.select),
logo_asset_id: logoValue(),
}),
});
@@ -173,6 +270,14 @@ export function renderB02ProjRegister(root: HTMLElement): void {
yearField.root,
lengthField.root,
memoField.root,
clientOrgField.root,
projectNumberField.root,
workAmountField.root,
designDateField.root,
pmField.root,
fieldLeadField.root,
designerField.root,
logoSlot,
);
const card = createCard({ body: [grid, submitBtn], raised: true });