From ac983cf0e1290da9c679b882479d30c410b79c1c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 19:21:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(B02):=20=EB=93=B1=EB=A1=9D=20=ED=8F=BC?= =?UTF-8?q?=EC=97=90=20=ED=91=9C=EC=A0=9C=EB=9E=80=208=EC=B9=B8=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-02) — 프로젝트 등록에서도 수정 모달과 같은 항목 수신. - 시행청·연도기번·사업량·설계일자·과업책임자·분야별책임자·설계자·회사 로고 8칸 추가. 담당자는 select + 「신규 등록…」(관리자만), 로고는 회사 대표 로고가 기본값 - 담당자·자산 회사 경계 검사를 check_project_refs(company_id, data) 로 옮겨 B01 수정과 공용 - GET /admin/members 문턱을 require_company 로 — 일반 사용자도 담당자 선택 필요. _scope_company 가 남의 회사를 막고 쓰기는 관리자 그대로 - fix: road_type 스키마가 옛 값(branch·stream)을 받고 화면 선택지 work 를 막아 「작업임도」 등록이 422 로 떨어지던 것 수정 — ^(main|fire|work)$ - 검증(5174 실조작): 등록 칸 14개로 수정 모달과 동일, 등록된 행에 8칸 그대로 저장 (road_type=work, logo_asset_id=1, designer=3), 해당 프로젝트 표제란 12칸 전부 채워짐. pytest 151 passed (신규 test_b02_register_schema.py 8건) Co-Authored-By: Claude Opus 5 (1M context) --- .../B01_Dashboard_Repository_Members.py | 23 ++++ B01_Dashboard/B01_Dashboard_Router.py | 28 +---- .../B02_ProjRegister_Repository.py | 17 ++- B02_ProjRegister/B02_ProjRegister_Router.py | 17 +++ B02_ProjRegister/B02_ProjRegister_Schema.py | 16 ++- B02_ProjRegister/B02_ProjRegister_UI_Page.ts | 105 ++++++++++++++++++ 6 files changed, 181 insertions(+), 25 deletions(-) diff --git a/B01_Dashboard/B01_Dashboard_Repository_Members.py b/B01_Dashboard/B01_Dashboard_Repository_Members.py index 7a47abfe..0a7d9c1d 100644 --- a/B01_Dashboard/B01_Dashboard_Repository_Members.py +++ b/B01_Dashboard/B01_Dashboard_Repository_Members.py @@ -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="로고·서명은 같은 회사 자산이어야 합니다.") diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index 7dd7b2a0..a0ba6723 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -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"} diff --git a/B02_ProjRegister/B02_ProjRegister_Repository.py b/B02_ProjRegister/B02_ProjRegister_Repository.py index da7989e9..06a1958f 100644 --- a/B02_ProjRegister/B02_ProjRegister_Repository.py +++ b/B02_ProjRegister/B02_ProjRegister_Repository.py @@ -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"), ), ) # 워크플로우 단계별 상태 초기화 시드 diff --git a/B02_ProjRegister/B02_ProjRegister_Router.py b/B02_ProjRegister/B02_ProjRegister_Router.py index f053b985..ec160b60 100644 --- a/B02_ProjRegister/B02_ProjRegister_Router.py +++ b/B02_ProjRegister/B02_ProjRegister_Router.py @@ -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: diff --git a/B02_ProjRegister/B02_ProjRegister_Schema.py b/B02_ProjRegister/B02_ProjRegister_Schema.py index 6b21c748..362ace0d 100644 --- a/B02_ProjRegister/B02_ProjRegister_Schema.py +++ b/B02_ProjRegister/B02_ProjRegister_Schema.py @@ -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): diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts index 7aa195ae..cc697a71 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts +++ b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts @@ -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 { 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 });