feat(incubator): 孵化器入驻与叶子节点管理

This commit is contained in:
Pine
2026-09-08 00:10:26 +08:00
parent ff234f7071
commit 60596ecafa
3 changed files with 280 additions and 2 deletions
+51
View File
@@ -0,0 +1,51 @@
"""创业园应用新增企业成员请假表 incubator_leaves。
权限:企业成员提交本人请假;企业管理员(company_members.is_admin
或园区管理员(carrier)审批;状态机 pending→approved/rejected→cancelled。
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0068_incubator_leaves"
down_revision = "0067_user_personal_balance"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
exists = bind.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'incubator_leaves'"
)
).scalar()
if exists:
return
op.create_table(
"incubator_leaves",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("tenant_id", sa.String(64), nullable=False, index=True),
sa.Column("company_id", sa.String(64), nullable=False, index=True),
sa.Column("company_name", sa.String(255), nullable=False, default=""),
sa.Column("user_id", sa.String(64), nullable=False, index=True),
sa.Column("user_name", sa.String(128), nullable=False, default=""),
sa.Column("leave_type", sa.String(32), nullable=False, default="personal"),
sa.Column("start_at", sa.String(32), nullable=False, default=""),
sa.Column("end_at", sa.String(32), nullable=False, default=""),
sa.Column("reason", sa.Text, nullable=False, default=""),
sa.Column("status", sa.String(32), nullable=False, default="pending", index=True),
sa.Column("reviewer_id", sa.String(64), nullable=False, default=""),
sa.Column("reviewer_name", sa.String(128), nullable=False, default=""),
sa.Column("review_comment", sa.Text, nullable=False, default=""),
sa.Column("reviewed_at", sa.String(32), nullable=False, default=""),
sa.Column("created_at", sa.String(32), nullable=False, default=""),
sa.Column("updated_at", sa.String(32), nullable=False, default=""),
mysql_charset="utf8mb4",
)
def downgrade() -> None:
op.drop_table("incubator_leaves")
+86
View File
@@ -661,3 +661,89 @@ async def incubator_area_schedule(
raise HTTPException(status_code=404, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
# ---------------------------------------------------------------------------
# 请假管理(企业成员提交 / 仅园区管理员审批)
# ---------------------------------------------------------------------------
class LeaveCreateRequest(BaseModel):
leave_type: str = Field("out", description="out/personal/sick/annual/other")
start_at: str = Field(..., description="开始时间 YYYY-MM-DD HH:MM")
end_at: str = Field(..., description="结束时间 YYYY-MM-DD HH:MM")
reason: str = ""
class LeaveReviewRequest(BaseModel):
action: str = Field(..., description="approve / reject")
comment: str = ""
@router.get("/leaves", summary="请假列表(按身份自动过滤:园区管理员=全园区/企业成员=本人)")
async def incubator_leaves(
status: str = "", company_id: str = "",
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
ident = await resolve_identity(db, user)
try:
return await service.list_leaves(db, ident, status=status, company_id=company_id)
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
@router.get("/leaves/{leave_id}", summary="请假详情")
async def incubator_leave_detail(
leave_id: str,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
ident = await resolve_identity(db, user)
try:
return await service.get_leave(db, ident, leave_id)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
@router.post("/leaves", summary="提交请假(企业成员本人)")
async def incubator_create_leave(
body: LeaveCreateRequest,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
ident = await resolve_identity(db, user)
try:
return await service.create_leave(db, ident, body.model_dump())
except (ValueError, PermissionError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/leaves/{leave_id}/review", summary="审批请假(仅园区管理员)")
async def incubator_review_leave(
leave_id: str, body: LeaveReviewRequest,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
ident = await resolve_identity(db, user)
try:
return await service.review_leave(db, ident, leave_id, body.action, body.comment)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ValueError, PermissionError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/leaves/{leave_id}/cancel", summary="撤回本人 pending 请假")
async def incubator_cancel_leave(
leave_id: str,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
ident = await resolve_identity(db, user)
try:
return await service.cancel_leave(db, ident, leave_id)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ValueError, PermissionError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
+143 -2
View File
@@ -24,6 +24,7 @@ from sqlalchemy import select
from ..infrastructure.models import (
CompanyMember,
IncubatorAreaReservation,
IncubatorLeave,
IncubatorMonthlyReport,
IncubatorSetting,
IncubatorSharedArea,
@@ -46,7 +47,8 @@ class Identity:
"""当前用户在本园区应用中的生效身份。"""
def __init__(self, *, is_manager: bool, tenant_id: str, company_id: str,
company_name: str, role: str, user_id: str, user_name: str = ""):
company_name: str, role: str, user_id: str, user_name: str = "",
is_company_admin: bool = False, member_type: str = "staff"):
self.is_manager = is_manager
self.tenant_id = tenant_id
self.company_id = company_id
@@ -54,6 +56,10 @@ class Identity:
self.role = role
self.user_id = user_id
self.user_name = user_name
# 企业内角色:is_company_admin=企业管理员(可审批本企业请假等);
# member_type=admin/staff/owner/migratedcompany_members.member_type
self.is_company_admin = is_company_admin
self.member_type = member_type
def to_dict(self) -> dict:
return {
@@ -64,6 +70,8 @@ class Identity:
"company_name": self.company_name,
"user_id": self.user_id,
"user_name": self.user_name,
"is_company_admin": self.is_company_admin,
"member_type": self.member_type,
}
@@ -117,7 +125,9 @@ async def resolve_identity(db, user: dict) -> Identity:
continue
return Identity(is_manager=False, tenant_id=tid, company_id=comp.id,
company_name=comp.name, role="opc_member", user_id=uid,
user_name=user_name)
user_name=user_name,
is_company_admin=bool(m.is_admin),
member_type=m.member_type or "staff")
# —— 无权限(非本园区账号) ——
return Identity(is_manager=False, tenant_id="", company_id="",
@@ -1081,3 +1091,134 @@ async def area_schedule(db, identity: Identity, area_id: str, *, days: int = 7)
"today": today.strftime("%Y-%m-%d"),
}
# ===========================================================================
# 请假管理(企业成员提交 / 企业管理员或园区管理员审批)
# ===========================================================================
LEAVE_TYPES = ("out", "personal", "sick", "annual", "other")
LEAVE_STATUSES = ("pending", "approved", "rejected", "cancelled")
async def list_leaves(db, identity: Identity, *, status: str = "",
company_id: str = "") -> dict:
"""请假列表:园区管理员=全园区;企业成员=本人。"""
require_identity(identity)
stmt = select(IncubatorLeave).where(IncubatorLeave.tenant_id == identity.tenant_id)
if identity.is_manager:
if company_id:
stmt = stmt.where(IncubatorLeave.company_id == company_id)
else:
stmt = stmt.where(IncubatorLeave.user_id == identity.user_id)
if status and status in LEAVE_STATUSES:
stmt = stmt.where(IncubatorLeave.status == status)
stmt = stmt.order_by(IncubatorLeave.created_at.desc())
rows = (await db.session.execute(stmt)).scalars().all()
items = [_leave_to_dict(r) for r in rows]
return {"items": items, "total": len(items)}
async def get_leave(db, identity: Identity, leave_id: str) -> dict:
require_identity(identity)
r = await db.session.get(IncubatorLeave, leave_id)
if r is None or r.tenant_id != identity.tenant_id:
raise LookupError("请假单不存在")
if not identity.is_manager and r.user_id != identity.user_id:
raise LookupError("请假单不存在")
return _leave_to_dict(r)
async def create_leave(db, identity: Identity, body: dict) -> dict:
"""企业成员提交本人请假。"""
require_identity(identity)
if not identity.company_id:
raise PermissionError("仅在园企业成员可提交请假")
leave_type = (body.get("leave_type") or "personal").strip()
if leave_type not in LEAVE_TYPES:
raise ValueError(f"请假类型仅支持:{', '.join(LEAVE_TYPES)}")
start_at = (body.get("start_at") or "").strip()
end_at = (body.get("end_at") or "").strip()
if not start_at or not end_at:
raise ValueError("请假起止时间必填")
if start_at >= end_at:
raise ValueError("结束时间必须晚于开始时间")
now = utcnow_iso()
r = IncubatorLeave(
id=f"leave_{secrets.token_hex(8)}",
tenant_id=identity.tenant_id,
company_id=identity.company_id,
company_name=identity.company_name,
user_id=identity.user_id,
user_name=identity.user_name or identity.user_id,
leave_type=leave_type,
start_at=start_at,
end_at=end_at,
reason=(body.get("reason") or "").strip(),
status="pending",
created_at=now,
updated_at=now,
)
db.session.add(r)
await db.session.commit()
return _leave_to_dict(r)
async def review_leave(db, identity: Identity, leave_id: str,
action: str, comment: str = "") -> dict:
"""审批请假:仅园区管理员(carrier / 园区绑定管理员)。"""
require_identity(identity)
if not identity.is_manager:
raise PermissionError("仅园区管理员可审批请假")
if action not in ("approve", "reject"):
raise ValueError("action 仅支持 approve / reject")
r = await db.session.get(IncubatorLeave, leave_id)
if r is None or r.tenant_id != identity.tenant_id:
raise LookupError("请假单不存在")
if r.status != "pending":
raise ValueError(f"当前状态 {r.status} 不可审批")
r.status = "approved" if action == "approve" else "rejected"
r.review_comment = comment.strip()
r.reviewer_id = identity.user_id
r.reviewer_name = identity.user_name or identity.user_id
r.reviewed_at = utcnow_iso()
r.updated_at = utcnow_iso()
await db.session.commit()
return _leave_to_dict(r)
async def cancel_leave(db, identity: Identity, leave_id: str) -> dict:
"""提交人撤回本人 pending 请假。"""
require_identity(identity)
r = await db.session.get(IncubatorLeave, leave_id)
if r is None or r.tenant_id != identity.tenant_id:
raise LookupError("请假单不存在")
if r.user_id != identity.user_id:
raise PermissionError("仅可撤回本人请假")
if r.status != "pending":
raise ValueError(f"当前状态 {r.status} 不可撤回")
r.status = "cancelled"
r.updated_at = utcnow_iso()
await db.session.commit()
return _leave_to_dict(r)
def _leave_to_dict(r: IncubatorLeave) -> dict:
return {
"id": r.id,
"tenant_id": r.tenant_id,
"company_id": r.company_id,
"company_name": r.company_name,
"user_id": r.user_id,
"user_name": r.user_name,
"leave_type": r.leave_type,
"start_at": r.start_at,
"end_at": r.end_at,
"reason": r.reason,
"status": r.status,
"reviewer_id": r.reviewer_id,
"reviewer_name": r.reviewer_name,
"review_comment": r.review_comment,
"reviewed_at": r.reviewed_at,
"created_at": r.created_at,
"updated_at": r.updated_at,
}