feat(B02): 등록 폼에 표제란 8칸 반영
사용자 지시(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) <noreply@anthropic.com>
This commit is contained in:
@@ -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,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:
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user