diff --git a/alembic/versions/0068_incubator_leaves.py b/alembic/versions/0068_incubator_leaves.py new file mode 100644 index 0000000..e76a347 --- /dev/null +++ b/alembic/versions/0068_incubator_leaves.py @@ -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") diff --git a/app/incubator/routers.py b/app/incubator/routers.py index cb5a01d..221e0f3 100644 --- a/app/incubator/routers.py +++ b/app/incubator/routers.py @@ -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 diff --git a/app/incubator/service.py b/app/incubator/service.py index 98c7281..5804011 100644 --- a/app/incubator/service.py +++ b/app/incubator/service.py @@ -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/migrated(company_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, + }