267 lines
11 KiB
Python
267 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""企业管理能力端点(/admin/ent)。
|
||
|
||
**企业管理员不是身份**:用户身份只有 运营方/载体方/OPC;「企业管理员」是
|
||
`company_members.is_admin` 能力标记(通常是园区入驻企业的 OPC 成员)。
|
||
任何登录用户只要 is_admin 绑定了企业,即可在本端点管理该企业:
|
||
查看/编辑企业资料、绑定企业用户(可代客开户)、发起企业整体转园申请。
|
||
铁律:不可删除企业/用户;不可直接调整所属园区(转园申请是唯一路径)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||
from pydantic import BaseModel
|
||
from sqlalchemy import select
|
||
|
||
from ..dependencies import get_db, get_current_user
|
||
from ...rbac import write_audit
|
||
from ...infrastructure.models import ParkCompany
|
||
from ...infrastructure.repositories import Database
|
||
from app.park import tenants as tnt
|
||
|
||
router = APIRouter(prefix="/admin/ent", tags=["enterprise"])
|
||
|
||
|
||
async def _ent_user(user: dict = Depends(get_current_user)) -> dict:
|
||
"""企业管理能力端点登录态(能力校验在各公司维度做 is_admin)。"""
|
||
return user
|
||
|
||
|
||
async def _my_company(db: Database, company_id: str, user: dict) -> ParkCompany:
|
||
"""校验当前用户是该企业的 is_admin 成员,返回企业 ORM 行。"""
|
||
m = await db.company_members.get(user["id"], company_id)
|
||
if m is None or not m.get("is_admin"):
|
||
raise HTTPException(status_code=403, detail="仅企业管理员可操作该企业")
|
||
comp = (await db.session.execute(
|
||
select(ParkCompany).where(ParkCompany.id == company_id))).scalars().first()
|
||
if comp is None:
|
||
raise HTTPException(status_code=404, detail="企业不存在")
|
||
return comp
|
||
|
||
|
||
@router.get("/my", summary="我管理的企业(is_admin 能力,非身份)")
|
||
async def ent_my(
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(_ent_user),
|
||
):
|
||
items = []
|
||
for m in await db.company_members.admin_of(user["id"]):
|
||
comp = (await db.session.execute(
|
||
select(ParkCompany).where(ParkCompany.id == m["company_id"]))).scalars().first()
|
||
if comp is None:
|
||
continue
|
||
d = tnt._co(comp)
|
||
d["member_count"] = len(await db.company_members.list_by_company(comp.id))
|
||
items.append(d)
|
||
return {"items": items}
|
||
|
||
|
||
@router.get("/my/joined", summary="我加入的企业(含普通成员,标注 my_is_admin)")
|
||
async def ent_my_joined(
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(_ent_user),
|
||
):
|
||
"""返回当前用户加入的所有企业,每个企业标注 my_is_admin 和我的成员信息。"""
|
||
# 预加载所有园区,用于 tenant_id → 园区名称 映射
|
||
all_tenants = await tnt.list_tenants()
|
||
tenant_name_map = {t["id"]: t["name"] for t in all_tenants}
|
||
|
||
items = []
|
||
for m in await db.company_members.list_by_user(user["id"]):
|
||
comp = (await db.session.execute(
|
||
select(ParkCompany).where(ParkCompany.id == m["company_id"]))).scalars().first()
|
||
if comp is None:
|
||
continue
|
||
d = tnt._co(comp)
|
||
d["tenant_name"] = tenant_name_map.get(comp.tenant_id, comp.tenant_id or "")
|
||
d["my_is_admin"] = bool(m.get("is_admin"))
|
||
d["my_membership"] = {
|
||
"user_id": user["id"],
|
||
"company_id": comp.id,
|
||
"is_admin": bool(m.get("is_admin")),
|
||
"member_type": m.get("member_type", ""),
|
||
"role": m.get("role", ""),
|
||
"compute_balance": m.get("compute_balance", 0),
|
||
"compute_balance_used": m.get("compute_balance_used", 0),
|
||
"created_at": m.get("created_at", ""),
|
||
}
|
||
d["member_count"] = len(await db.company_members.list_by_company(comp.id))
|
||
items.append(d)
|
||
return {"items": items}
|
||
|
||
|
||
@router.get("/companies/{cid}", summary="企业详情(本企业管理员)")
|
||
async def ent_company(
|
||
cid: str,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(_ent_user),
|
||
):
|
||
comp = await _my_company(db, cid, user)
|
||
d = tnt._co(comp)
|
||
d["members"] = await tnt.company_members(cid)
|
||
return d
|
||
|
||
|
||
class EntCompanyUpdateBody(BaseModel):
|
||
name: str | None = None
|
||
industry: str | None = None
|
||
bio: str | None = None
|
||
address: str | None = None
|
||
contact_phone: str | None = None
|
||
legal_person: str | None = None
|
||
legal_phone: str | None = None
|
||
registered_capital: str | None = None
|
||
founded_at: str | None = None
|
||
employees: int | None = None
|
||
|
||
|
||
@router.put("/companies/{cid}", summary="编辑企业资料(不可删除、不可改所属园区)")
|
||
async def ent_update_company(
|
||
cid: str,
|
||
body: EntCompanyUpdateBody,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(_ent_user),
|
||
):
|
||
comp = await _my_company(db, cid, user)
|
||
for k, v in body.model_dump(exclude_none=True).items():
|
||
setattr(comp, k, v)
|
||
await db.session.commit()
|
||
await write_audit(db, action="ent.company_edit", resource="park_company",
|
||
resource_id=cid, user=user, request=request)
|
||
return tnt._co(comp)
|
||
|
||
|
||
class EntMemberBody(BaseModel):
|
||
"""绑定企业用户:mode=existing(user_id/keyword 命中已有账号)| new(代客开户,默认 opc_member 防提权)。"""
|
||
mode: str = "existing"
|
||
user_id: str = ""
|
||
keyword: str = ""
|
||
phone: str = ""
|
||
password: str = ""
|
||
nickname: str = ""
|
||
|
||
|
||
@router.get("/companies/{cid}/members", summary="企业成员列表")
|
||
async def ent_members(
|
||
cid: str,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(_ent_user),
|
||
):
|
||
await _my_company(db, cid, user)
|
||
return {"items": await tnt.company_members(cid)}
|
||
|
||
|
||
@router.post("/companies/{cid}/members", summary="绑定企业用户(绑定已有 / 代客开户)")
|
||
async def ent_add_member(
|
||
cid: str,
|
||
body: EntMemberBody,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(_ent_user),
|
||
):
|
||
target_id = body.user_id
|
||
if body.mode == "new":
|
||
from ...services.user_admin_service import create_username, validate_phone
|
||
if not body.nickname.strip() or not body.password:
|
||
raise HTTPException(status_code=400, detail="代客开户须提供姓名与初始密码")
|
||
phone = validate_phone(body.phone)
|
||
if await db.users.find_by_phone(phone):
|
||
raise HTTPException(status_code=400, detail="手机号已被使用")
|
||
uname = await create_username(db)
|
||
u = await db.users.create(uname, body.password, nickname=body.nickname, phone=phone,
|
||
source="enterprise", role="opc_member")
|
||
target_id = u["id"]
|
||
elif not target_id and body.keyword.strip():
|
||
hits = await db.users.search(body.keyword, limit=1)
|
||
if not hits:
|
||
raise HTTPException(status_code=404, detail="未找到该账号")
|
||
target_id = hits[0]["id"]
|
||
if await db.users.get_by_id(target_id) is None:
|
||
raise HTTPException(status_code=404, detail="用户不存在")
|
||
r = await tnt.add_company_member(cid, target_id, created_by=user.get("id", ""))
|
||
if r is None:
|
||
raise HTTPException(status_code=404, detail="企业不存在")
|
||
from ...services.membership_service import sync_user_affiliation
|
||
await sync_user_affiliation(db, target_id)
|
||
await write_audit(db, action="ent.member_bind", resource="company_member",
|
||
resource_id=target_id, detail=f"company={cid}", user=user, request=request)
|
||
return r
|
||
|
||
|
||
@router.delete("/companies/{cid}/members/{uid}", summary="移出企业用户(管理员不可被移出)")
|
||
async def ent_remove_member(
|
||
cid: str,
|
||
uid: str,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(_ent_user),
|
||
):
|
||
await _my_company(db, cid, user)
|
||
r = await tnt.remove_company_member(cid, uid)
|
||
if r is None or not r.get("ok"):
|
||
raise HTTPException(status_code=400, detail=(r or {}).get("reason", "移出失败"))
|
||
from ...services.membership_service import sync_user_affiliation
|
||
await sync_user_affiliation(db, uid)
|
||
await write_audit(db, action="ent.member_remove", resource="company_member",
|
||
resource_id=uid, detail=f"company={cid}", user=user, request=request)
|
||
return r
|
||
|
||
|
||
class EntTransferBody(BaseModel):
|
||
to_park_id: str
|
||
reason: str = ""
|
||
|
||
|
||
@router.post("/park-transfers", summary="企业管理员发起企业整体转园申请(两级审核)")
|
||
async def ent_propose_transfer(
|
||
body: EntTransferBody,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(_ent_user),
|
||
):
|
||
from ...domain import park_transfer as pt_rules
|
||
from ...infrastructure.models import ParkTenant
|
||
|
||
comps = await db.company_members.admin_of(user["id"])
|
||
if not comps:
|
||
raise HTTPException(status_code=403, detail="您未管理任何企业")
|
||
if len(comps) > 1:
|
||
raise HTTPException(status_code=400, detail="您管理多家企业,请分别在各企业上下文发起")
|
||
comp = (await db.session.execute(
|
||
select(ParkCompany).where(ParkCompany.id == comps[0]["company_id"]))).scalars().first()
|
||
if comp is None:
|
||
raise HTTPException(status_code=404, detail="企业不存在")
|
||
from_park_id = comp.tenant_id or ""
|
||
if not from_park_id:
|
||
raise HTTPException(status_code=400, detail="独立企业无所属园区,无需转园")
|
||
if body.to_park_id == from_park_id:
|
||
raise HTTPException(status_code=400, detail="目标园区与当前园区相同")
|
||
to_t = (await db.session.execute(
|
||
select(ParkTenant).where(ParkTenant.id == body.to_park_id))).scalars().first()
|
||
if to_t is None:
|
||
raise HTTPException(status_code=404, detail="目标园区不存在")
|
||
from_t = await tnt.get_tenant(from_park_id)
|
||
tr = await db.park_transfers.create({
|
||
"user_id": "", "username": user.get("username", ""),
|
||
"from_park_id": from_park_id, "from_park_name": (from_t or {}).get("name", ""),
|
||
"to_park_id": body.to_park_id, "to_park_name": getattr(to_t, "name", ""),
|
||
"reason": body.reason, "stage": pt_rules.initial_stage(from_t), "status": "pending",
|
||
"initiator_role": "enterprise", "target_kind": "company", "company_id": comp.id,
|
||
}, "", user.get("username", ""))
|
||
await write_audit(db, action="ent.transfer_apply", resource="park_transfer",
|
||
resource_id=tr["id"], detail=f"to={body.to_park_id}", user=user, request=request)
|
||
return {"ok": True, "transfer": tr}
|
||
|
||
|
||
@router.get("/park-transfers", summary="本企业相关转园申请")
|
||
async def ent_transfers(
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(_ent_user),
|
||
):
|
||
comps = await db.company_members.admin_of(user["id"])
|
||
items = []
|
||
for m in comps:
|
||
items.extend(await db.park_transfers.list(company_id=m["company_id"]))
|
||
return {"items": items}
|