사용자 지시(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>
135 lines
5.5 KiB
Python
135 lines
5.5 KiB
Python
"""회사 구성원과 회사 대표 로고 저장소 (B01_Dashboard_Repository 에서 분리, 700줄 제한).
|
|
|
|
구성원은 도면 표제란의 사람 자리(과업책임자·분야별책임자·설계자)를 채우는 원천이라
|
|
자산·로고와 같은 결로 묶어 둔다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
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]]:
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
|
await cursor.execute(
|
|
"""SELECT id, email, name, position, department, role, is_master, status
|
|
FROM users WHERE company_id = %s AND deleted_at IS NULL ORDER BY name, email""",
|
|
(company_id,),
|
|
)
|
|
rows = list(await cursor.fetchall())
|
|
for row in rows:
|
|
row["role"] = _role(row.get("role"))
|
|
row["is_master"] = bool(row.get("is_master"))
|
|
return rows
|
|
|
|
|
|
async def add_company_member(
|
|
company_id: int,
|
|
email: str,
|
|
profile: dict[str, Any] | None = None,
|
|
) -> dict[str, Any] | None:
|
|
"""회사에 사람을 붙인다.
|
|
|
|
`profile["name"]` 이 있으면 **계정이 없는 사람도 그 자리에서 만든다**
|
|
(2026-09-02 사용자 확정 — 담당자 선택의 「신규 등록…」). 새 계정은 로그인할 수 없는
|
|
비밀번호로 서고(`status='PENDING'`), 본인이 비밀번호를 세우면 그때 쓰인다.
|
|
"""
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
|
await connection.begin()
|
|
await cursor.execute(
|
|
"""SELECT id, company_id FROM users
|
|
WHERE email = %s AND deleted_at IS NULL FOR UPDATE""",
|
|
(email.lower(),),
|
|
)
|
|
user = await cursor.fetchone()
|
|
if not user and profile and profile.get("name"):
|
|
await cursor.execute(
|
|
"""INSERT INTO users (email, password_hash, name, position, department,
|
|
company_id, role, status)
|
|
VALUES (%s, %s, %s, %s, %s, %s, 'USER', 'PENDING')""",
|
|
(
|
|
email.lower(),
|
|
hash_password(secrets.token_urlsafe(32)), # 아무도 못 맞히는 비밀번호
|
|
profile["name"],
|
|
profile.get("position"),
|
|
profile.get("department"),
|
|
company_id,
|
|
),
|
|
)
|
|
await connection.commit()
|
|
return await get_dashboard_me(cursor.lastrowid)
|
|
if not user or user["company_id"] == company_id:
|
|
await connection.rollback()
|
|
return None
|
|
await cursor.execute(
|
|
"""UPDATE users SET company_id = %s, status = 'ACTIVE', role = 'USER',
|
|
is_master = FALSE
|
|
WHERE id = %s""",
|
|
(company_id, user["id"]),
|
|
)
|
|
await connection.commit()
|
|
return await get_dashboard_me(user["id"])
|
|
|
|
|
|
async def remove_company_member(company_id: int, user_id: int) -> bool:
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"""UPDATE users SET company_id = NULL, status = 'NO_COMPANY', role = 'USER',
|
|
is_master = FALSE
|
|
WHERE id = %s AND company_id = %s AND is_master = FALSE""",
|
|
(user_id, company_id),
|
|
)
|
|
changed = cursor.rowcount > 0
|
|
await connection.commit()
|
|
return changed
|
|
|
|
|
|
async def set_company_logo(company_id: int, asset_id: int | None) -> bool:
|
|
"""회사 대표 로고를 지정한다 (2026-09-02 사용자 확정 — 회사 등록 단계에서 받고 변경).
|
|
|
|
`asset_id` 가 같은 회사의 `kind='LOGO'` 자산인지는 라우터가 확인한다.
|
|
"""
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
|
await cursor.execute(
|
|
"UPDATE companies SET logo_asset_id = %s WHERE id = %s AND deleted_at IS NULL",
|
|
(asset_id, company_id),
|
|
)
|
|
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="로고·서명은 같은 회사 자산이어야 합니다.")
|