Files
server-core/app/incubator/service.py
T

1225 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""创业园运营子应用(app/incubator)—— 月度报告 / 共享区域 / 使用申请。
业务域:
- 月度运行情况报告(对齐《入驻创业企业(项目)运行情况统计表》)
- 共享区域资源(共享直播间 / 会议室)
- 共享区域使用申请(对齐《共享区域使用登记表》)
身份语义(与《昆明大学生创业园运营系统(PawApp)设计方案》一致):
- 管理方:carrier 且归属本园区(park_tenants.operator_user_id=user.id),
或具备权限码 menu:park_incubator / incubator:manage —— 可看全园区、审核/管理。
- 提交方:园区内入驻企业成员(company_members 关联 park_companiestenant_id 非空)
—— 仅本企业数据。
DB 访问统一走 ``db.session``select / get),与 market 子应用保持一致。
"""
from __future__ import annotations
import json
import secrets
from datetime import datetime
from sqlalchemy import select
from ..infrastructure.models import (
CompanyMember,
IncubatorAreaReservation,
IncubatorLeave,
IncubatorMonthlyReport,
IncubatorSetting,
IncubatorSharedArea,
ParkCompany,
ParkTenant,
User,
)
from ..infrastructure.repositories import utcnow_iso
# 本应用唯一服务的园区(昆明市大学生创业园):身份与数据范围都限定该园区。
# 管理权限码(与 seed.py role=carrier 权限对齐,保留供细粒度授权扩展)
TARGET_PARK_NAME = "昆明市大学生创业园"
MANAGE_PERMS = ("menu:park_incubator", "incubator:manage")
# ===========================================================================
# 身份解析
# ===========================================================================
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 = "",
is_company_admin: bool = False, member_type: str = "staff"):
self.is_manager = is_manager
self.tenant_id = tenant_id
self.company_id = company_id
self.company_name = company_name
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 {
"is_manager": self.is_manager,
"role": self.role,
"tenant_id": self.tenant_id,
"company_id": self.company_id,
"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,
}
async def resolve_identity(db, user: dict) -> Identity:
"""解析当前用户在「昆明市大学生创业园」应用中的生效身份。
本应用仅服务「昆明市大学生创业园」这一个园区(按名称精确定位,
不依赖园区 id,环境里若有同名园区即为目标):
- 管理方 = 该园区的管理员(park_tenants.operator_user_id 或 admin_username 绑定)
- 提交方 = 该园区(tenant_id 一致)的在园企业成员
- 其他 = 无权限(不属于本园区)
"""
uid = user.get("id", "")
username = user.get("username", "")
role = user.get("role", "")
user_name = user.get("nickname", "") or username
# 目标园区:昆明市大学生创业园(active)
target = (await db.session.execute(
select(ParkTenant).where(ParkTenant.name == TARGET_PARK_NAME,
ParkTenant.status == "active")
)).scalar_one_or_none()
if target is None:
# 环境未配置目标园区 → 一律无权限
return Identity(is_manager=False, tenant_id="", company_id="",
company_name="", role=role or "opc_member", user_id=uid,
user_name=user_name)
tid = target.id
# —— 管理方:目标园区的管理员(operator_user_id 或 admin_username 绑定) ——
if target.operator_user_id == uid or target.admin_username == username:
return Identity(is_manager=True, tenant_id=tid, company_id="",
company_name="", role="carrier:admin", user_id=uid,
user_name=user_name)
# 兜底:平台运营方(operator)账号可管理本园区(天然全权限)。
# 说明:本应用按“园区管理员”语义放行平台运营账号,便于运营方直接查看/
# 审核全园区申请与月报;若需收紧,仅保留 yunpeng 绑定即可删去该分支。
if role == "operator":
return Identity(is_manager=True, tenant_id=tid, company_id="",
company_name="", role="operator:admin", user_id=uid,
user_name=user_name)
# —— 提交方:目标园区的在园企业成员 ——
rows = (await db.session.execute(
select(CompanyMember).where(CompanyMember.user_id == uid,
CompanyMember.status == "active")
)).scalars().all()
for m in rows:
comp = await db.session.get(ParkCompany, m.company_id)
if comp is None or comp.tenant_id != tid:
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,
is_company_admin=bool(m.is_admin),
member_type=m.member_type or "staff")
# —— 无权限(非本园区账号) ——
return Identity(is_manager=False, tenant_id="", company_id="",
company_name="", role=role or "opc_member", user_id=uid,
user_name=user_name)
def require_identity(identity: Identity) -> None:
"""身份不可用(非昆明市大学生创业园管理员且未绑定本园企业)时抛异常。"""
if not identity.is_manager and not identity.company_id:
raise LookupError("当前账号不属于昆明市大学生创业园,无法使用本应用")
# ===========================================================================
# 通用工具
# ===========================================================================
def _now() -> str:
return utcnow_iso()
def _parse_float(v) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _parse_int(v) -> int | None:
if v is None or v == "":
return None
try:
return int(float(v))
except (TypeError, ValueError):
return None
# ===========================================================================
# 月度报告
# ===========================================================================
def _report_row(r: IncubatorMonthlyReport) -> dict:
return {
"id": r.id, "tenant_id": r.tenant_id, "company_id": r.company_id,
"company_name": r.company_name, "report_month": r.report_month,
"status": r.status,
"zone": r.zone, "unit_no": r.unit_no, "park_entry_date": r.park_entry_date,
"leader_name": r.leader_name, "registered_at": r.registered_at,
"registered_capital_wan": r.registered_capital_wan, "credit_code": r.credit_code,
"rd_invest_wan": r.rd_invest_wan, "month_revenue_wan": r.month_revenue_wan,
"avg_year_revenue_wan": r.avg_year_revenue_wan,
"month_gross_profit_wan": r.month_gross_profit_wan,
"month_tax_wan": r.month_tax_wan, "avg_year_tax_wan": r.avg_year_tax_wan,
"loan_startup_wan": r.loan_startup_wan, "loan_yunling_wan": r.loan_yunling_wan,
"contest_award_wan": r.contest_award_wan, "patents": r.patents,
"jobs_created": r.jobs_created,
"team": json.loads(r.team_json or "[]"),
"business_note": r.business_note, "suggestion": r.suggestion,
"filler_name": r.filler_name, "filler_email": r.filler_email,
"submitted_at": r.submitted_at,
"reviewer_id": r.reviewer_id, "review_comment": r.review_comment,
"reviewed_at": r.reviewed_at,
"created_at": r.created_at, "updated_at": r.updated_at,
}
async def list_reports(db, identity: Identity, *, month: str = "", status: str = "",
page: int = 1, page_size: int = 50) -> dict:
"""报告列表:管理方=全园区;提交方=本企业。"""
require_identity(identity)
q = select(IncubatorMonthlyReport).where(
IncubatorMonthlyReport.tenant_id == identity.tenant_id)
if not identity.is_manager:
q = q.where(IncubatorMonthlyReport.company_id == identity.company_id)
if month:
q = q.where(IncubatorMonthlyReport.report_month == month)
if status:
q = q.where(IncubatorMonthlyReport.status == status)
q = q.order_by(IncubatorMonthlyReport.report_month.desc())
total = len((await db.session.execute(q)).scalars().all())
rows = (await db.session.execute(q.offset((page - 1) * page_size).limit(page_size))).scalars().all()
return {"items": [_report_row(r) for r in rows], "total": total, "page": page,
"page_size": page_size}
async def get_report(db, identity: Identity, report_id: str) -> dict:
r = await db.session.get(IncubatorMonthlyReport, report_id)
if r is None:
raise LookupError("报告不存在")
_check_tenant_access(identity, r.tenant_id, r.company_id)
return _report_row(r)
def _check_tenant_access(identity: Identity, tenant_id: str, company_id: str) -> None:
if identity.is_manager:
if identity.tenant_id and identity.tenant_id != tenant_id:
raise PermissionError("无权访问其他园区数据")
else:
if not identity.company_id or company_id != identity.company_id:
raise PermissionError("仅可访问本企业数据")
async def create_report(db, identity: Identity, data: dict) -> dict:
"""新建/保存草稿(提交方)。同一企业+月份已存在则返回既有记录。"""
require_identity(identity)
if identity.is_manager:
raise PermissionError("管理方不填报报告")
month = str(data.get("report_month", "")).strip()
if not month:
raise ValueError("report_month 必填(如 2026-08")
existing = (await db.session.execute(
select(IncubatorMonthlyReport).where(
IncubatorMonthlyReport.tenant_id == identity.tenant_id,
IncubatorMonthlyReport.company_id == identity.company_id,
IncubatorMonthlyReport.report_month == month)
)).scalar_one_or_none()
now = _now()
if existing is not None:
return await update_report(db, identity, existing.id, data)
r = IncubatorMonthlyReport(
id=f"mr_{secrets.token_hex(8)}",
tenant_id=identity.tenant_id, company_id=identity.company_id,
company_name=data.get("company_name") or identity.company_name,
report_month=month, status="draft", created_at=now, updated_at=now,
)
_apply_report_fields(r, data)
db.session.add(r)
await db.session.commit()
return _report_row(r)
def _apply_report_fields(r: IncubatorMonthlyReport, data: dict) -> None:
r.company_name = data.get("company_name") or r.company_name
r.zone = str(data.get("zone", "") or r.zone)
r.unit_no = str(data.get("unit_no", "") or r.unit_no)
r.park_entry_date = str(data.get("park_entry_date", "") or r.park_entry_date)
r.leader_name = str(data.get("leader_name", "") or r.leader_name)
r.registered_at = str(data.get("registered_at", "") or r.registered_at)
r.registered_capital_wan = _parse_float(data.get("registered_capital_wan"))
r.credit_code = str(data.get("credit_code", "") or r.credit_code)
r.rd_invest_wan = _parse_float(data.get("rd_invest_wan"))
r.month_revenue_wan = _parse_float(data.get("month_revenue_wan"))
r.avg_year_revenue_wan = _parse_float(data.get("avg_year_revenue_wan"))
r.month_gross_profit_wan = _parse_float(data.get("month_gross_profit_wan"))
r.month_tax_wan = _parse_float(data.get("month_tax_wan"))
r.avg_year_tax_wan = _parse_float(data.get("avg_year_tax_wan"))
r.loan_startup_wan = _parse_float(data.get("loan_startup_wan"))
r.loan_yunling_wan = _parse_float(data.get("loan_yunling_wan"))
r.contest_award_wan = _parse_float(data.get("contest_award_wan"))
r.patents = _parse_int(data.get("patents"))
r.jobs_created = _parse_int(data.get("jobs_created"))
team = data.get("team")
if isinstance(team, list):
r.team_json = json.dumps(team[:50], ensure_ascii=False)
r.business_note = str(data.get("business_note", "") or r.business_note)
r.suggestion = str(data.get("suggestion", "") or r.suggestion)
r.filler_name = str(data.get("filler_name", "") or r.filler_name)
r.filler_email = str(data.get("filler_email", "") or r.filler_email)
async def update_report(db, identity: Identity, report_id: str, data: dict) -> dict:
"""更新报告(draft,或 submitted 未审核可撤回重改)。"""
require_identity(identity)
r = await db.session.get(IncubatorMonthlyReport, report_id)
if r is None:
raise LookupError("报告不存在")
if identity.company_id != r.company_id:
raise PermissionError("仅可编辑本企业报告")
if r.status not in ("draft", "submitted"):
raise ValueError("已审核报告不可再编辑")
if r.status == "submitted" and r.submitted_at and not data.get("_force_reopen"):
raise ValueError("报告已提交,如需修改请先撤回")
_apply_report_fields(r, data)
r.updated_at = _now()
await db.session.commit()
return _report_row(r)
async def submit_report(db, identity: Identity, report_id: str) -> dict:
"""提交审核:draft → submitted。"""
require_identity(identity)
r = await db.session.get(IncubatorMonthlyReport, report_id)
if r is None:
raise LookupError("报告不存在")
if identity.company_id != r.company_id:
raise PermissionError("仅可提交本企业报告")
if r.status != "draft":
raise ValueError("仅草稿可提交")
now = _now()
r.status = "submitted"
r.submitted_at = r.submitted_at or now
r.updated_at = now
await db.session.commit()
return _report_row(r)
async def reopen_report(db, identity: Identity, report_id: str) -> dict:
"""撤回:submitted(未审核) → draft,允许重改。"""
require_identity(identity)
r = await db.session.get(IncubatorMonthlyReport, report_id)
if r is None:
raise LookupError("报告不存在")
if identity.company_id != r.company_id:
raise PermissionError("仅可撤回本企业报告")
if r.status != "submitted":
raise ValueError("仅已提交未审核的报告可撤回")
r.status = "draft"
r.updated_at = _now()
await db.session.commit()
return _report_row(r)
async def review_report(db, identity: Identity, report_id: str, *,
action: str, comment: str = "") -> dict:
"""审核:submitted → approved / rejected(管理方)。"""
if not identity.is_manager:
raise PermissionError("仅管理方可审核")
if action not in ("approve", "reject"):
raise ValueError("action 仅支持 approve/reject")
r = await db.session.get(IncubatorMonthlyReport, report_id)
if r is None:
raise LookupError("报告不存在")
_check_tenant_access(identity, r.tenant_id, r.company_id)
if r.status != "submitted":
raise ValueError("仅已提交报告可审核")
now = _now()
r.status = "approved" if action == "approve" else "rejected"
r.reviewer_id = identity.user_id
r.review_comment = comment
r.reviewed_at = now
r.updated_at = now
await db.session.commit()
return _report_row(r)
async def report_stats(db, identity: Identity, month: str = "") -> dict:
"""管理方:报告统计(提交率 / 关键指标合计 / 贷款扶持合计)。"""
if not identity.is_manager:
raise PermissionError("仅管理方可查看统计")
q = select(IncubatorMonthlyReport).where(
IncubatorMonthlyReport.tenant_id == identity.tenant_id)
if month:
q = q.where(IncubatorMonthlyReport.report_month == month)
rows = (await db.session.execute(q)).scalars().all()
comp_q = select(ParkCompany).where(
ParkCompany.tenant_id == identity.tenant_id,
ParkCompany.status == "active")
companies = (await db.session.execute(comp_q)).scalars().all()
submitted = [r for r in rows if r.status in ("submitted", "approved", "rejected")]
approved = [r for r in rows if r.status == "approved"]
def _sum(attr):
return round(sum(getattr(r, attr) or 0 for r in rows), 2)
return {
"month": month,
"companies_total": len(companies),
"reports_total": len(rows),
"submitted": len(submitted),
"approved": len(approved),
"pending_review": len([r for r in rows if r.status == "submitted"]),
"sum_month_revenue_wan": _sum("month_revenue_wan"),
"sum_avg_year_revenue_wan": _sum("avg_year_revenue_wan"),
"sum_month_gross_profit_wan": _sum("month_gross_profit_wan"),
"sum_loan_startup_wan": _sum("loan_startup_wan"),
"sum_loan_yunling_wan": _sum("loan_yunling_wan"),
"sum_contest_award_wan": _sum("contest_award_wan"),
"sum_patents": int(_sum("patents")),
"sum_jobs_created": int(_sum("jobs_created")),
}
# ===========================================================================
# 共享区域资源
# ===========================================================================
def _area_row(r: IncubatorSharedArea) -> dict:
return {
"id": r.id, "tenant_id": r.tenant_id, "name": r.name,
"area_type": r.area_type, "location": r.location, "capacity": r.capacity,
"facilities": json.loads(r.facilities_json or "[]"),
"cover_url": r.cover_url,
"open_hour_start": r.open_hour_start, "open_hour_end": r.open_hour_end,
"max_minutes": r.max_minutes, "enabled": bool(r.enabled),
"status": r.status or "normal", "status_note": r.status_note or "",
"created_at": r.created_at, "updated_at": r.updated_at,
}
async def list_areas(db, identity: Identity, *, area_type: str = "",
only_enabled: bool = True) -> dict:
require_identity(identity)
q = select(IncubatorSharedArea).where(
IncubatorSharedArea.tenant_id == identity.tenant_id)
if area_type:
q = q.where(IncubatorSharedArea.area_type == area_type)
if only_enabled:
q = q.where(IncubatorSharedArea.enabled.is_(True))
rows = (await db.session.execute(q.order_by(IncubatorSharedArea.created_at))).scalars().all()
return {"items": [_area_row(r) for r in rows], "total": len(rows)}
async def get_area(db, identity: Identity, area_id: str) -> dict:
r = await db.session.get(IncubatorSharedArea, area_id)
if r is None:
raise LookupError("共享区域不存在")
if r.tenant_id != identity.tenant_id:
raise PermissionError("无权访问其他园区数据")
return _area_row(r)
async def create_area(db, identity: Identity, data: dict) -> dict:
if not identity.is_manager:
raise PermissionError("仅管理方可管理共享区域")
if str(data.get("area_type", "")) not in ("live", "meeting"):
raise ValueError("area_type 仅支持 live/meeting")
now = _now()
r = IncubatorSharedArea(
id=f"area_{secrets.token_hex(8)}",
tenant_id=identity.tenant_id,
name=str(data.get("name", "")).strip(),
area_type=str(data.get("area_type", "meeting")),
location=str(data.get("location", "")),
capacity=_parse_int(data.get("capacity")),
facilities_json=json.dumps(data.get("facilities") or [], ensure_ascii=False),
cover_url=str(data.get("cover_url", "")),
open_hour_start=str(data.get("open_hour_start", "09:00")),
open_hour_end=str(data.get("open_hour_end", "18:00")),
max_minutes=_parse_int(data.get("max_minutes")),
enabled=bool(data.get("enabled", True)),
status=str(data.get("status", "normal")) or "normal",
status_note=str(data.get("status_note", "")),
created_at=now, updated_at=now,
)
if not r.name:
raise ValueError("name 必填")
db.session.add(r)
await db.session.commit()
return _area_row(r)
async def update_area(db, identity: Identity, area_id: str, data: dict) -> dict:
if not identity.is_manager:
raise PermissionError("仅管理方可管理共享区域")
r = await db.session.get(IncubatorSharedArea, area_id)
if r is None:
raise LookupError("共享区域不存在")
if r.tenant_id != identity.tenant_id:
raise PermissionError("无权操作其他园区数据")
if "name" in data and data["name"] is not None:
r.name = str(data["name"]).strip()
if "area_type" in data and data["area_type"]:
if str(data["area_type"]) not in ("live", "meeting"):
raise ValueError("area_type 仅支持 live/meeting")
r.area_type = str(data["area_type"])
for k, attr in (("location", "location"), ("cover_url", "cover_url"),
("open_hour_start", "open_hour_start"), ("open_hour_end", "open_hour_end")):
if k in data and data[k] is not None:
setattr(r, attr, str(data[k]))
if "capacity" in data:
r.capacity = _parse_int(data["capacity"])
if "max_minutes" in data:
r.max_minutes = _parse_int(data["max_minutes"])
if "facilities" in data and isinstance(data["facilities"], list):
r.facilities_json = json.dumps(data["facilities"], ensure_ascii=False)
if "enabled" in data and isinstance(data["enabled"], bool):
r.enabled = data["enabled"]
if "status" in data and data["status"] is not None:
st = str(data["status"])
if st not in ("normal", "maintenance", "closed"):
raise ValueError("status 仅支持 normal/maintenance/closed")
r.status = st
if "status_note" in data and data["status_note"] is not None:
r.status_note = str(data["status_note"])
r.updated_at = _now()
await db.session.commit()
return _area_row(r)
async def delete_area(db, identity: Identity, area_id: str) -> bool:
if not identity.is_manager:
raise PermissionError("仅管理方可管理共享区域")
r = await db.session.get(IncubatorSharedArea, area_id)
if r is None:
return False
if r.tenant_id != identity.tenant_id:
raise PermissionError("无权操作其他园区数据")
await db.session.delete(r)
await db.session.commit()
return True
# ===========================================================================
# 共享区域使用申请(对齐《共享区域使用登记表》)
# ===========================================================================
def _rsv_row(r: IncubatorAreaReservation) -> dict:
return {
"id": r.id, "tenant_id": r.tenant_id, "area_id": r.area_id,
"area_name": r.area_name, "area_type": r.area_type,
"company_id": r.company_id, "company_name": r.company_name,
"user_id": r.user_id, "start_at": r.start_at, "end_at": r.end_at,
"attendees": r.attendees, "purpose": r.purpose,
"leader_name": r.leader_name, "leader_phone": r.leader_phone,
"contact_name": r.contact_name, "contact_phone": r.contact_phone,
"agreed_commitment": bool(r.agreed_commitment), "signer_name": r.signer_name,
"status": r.status,
"reviewer_id": r.reviewer_id, "review_comment": r.review_comment,
"reviewed_at": r.reviewed_at,
"record_received": bool(r.record_received),
"record_safety_notified": bool(r.record_safety_notified),
"record_handler": r.record_handler, "record_at": r.record_at,
"created_at": r.created_at, "updated_at": r.updated_at,
}
async def _conflict_exists(db, *, area_id: str, start_at: str, end_at: str,
exclude_id: str = "") -> bool:
"""同一区域时间重叠冲突检测(pending/approved 有效占用)。"""
q = select(IncubatorAreaReservation).where(
IncubatorAreaReservation.area_id == area_id,
IncubatorAreaReservation.status.in_(("pending", "approved")),
)
for r in (await db.session.execute(q)).scalars().all():
if r.id == exclude_id:
continue
if r.start_at < end_at and start_at < r.end_at:
return True
return False
async def list_reservations(db, identity: Identity, *, date: str = "", status: str = "",
area_id: str = "", page: int = 1, page_size: int = 50) -> dict:
"""申请列表:管理方=全园区;提交方=本企业。"""
require_identity(identity)
q = select(IncubatorAreaReservation).where(
IncubatorAreaReservation.tenant_id == identity.tenant_id)
if not identity.is_manager:
q = q.where(IncubatorAreaReservation.company_id == identity.company_id)
if date:
q = q.where(IncubatorAreaReservation.start_at.like(f"{date}%"))
if status:
q = q.where(IncubatorAreaReservation.status == status)
if area_id:
q = q.where(IncubatorAreaReservation.area_id == area_id)
q = q.order_by(IncubatorAreaReservation.start_at.desc())
total = len((await db.session.execute(q)).scalars().all())
rows = (await db.session.execute(q.offset((page - 1) * page_size).limit(page_size))).scalars().all()
return {"items": [_rsv_row(r) for r in rows], "total": total,
"page": page, "page_size": page_size}
async def create_reservation(db, identity: Identity, data: dict) -> dict:
"""发起共享区域使用申请(提交方=本企业;管理方=任意企业名)。"""
require_identity(identity)
area_id = str(data.get("area_id", "")).strip()
area = await db.session.get(IncubatorSharedArea, area_id) if area_id else None
if area is None:
raise LookupError("共享区域不存在")
if area.tenant_id != identity.tenant_id:
raise PermissionError("无权使用其他园区共享区域")
if not area.enabled:
raise ValueError("该共享区域已停用")
start_at = str(data.get("start_at", "")).strip()
end_at = str(data.get("end_at", "")).strip()
if not start_at or not end_at:
raise ValueError("start_at / end_at 必填(YYYY-MM-DD HH:MM")
if start_at >= end_at:
raise ValueError("使用结束时间必须晚于开始时间")
# 开放时段与时长校验(open_hour_start/end 为 HH:MMmax_minutes 分钟)
try:
dt_start = datetime.fromisoformat(start_at)
dt_end = datetime.fromisoformat(end_at)
except ValueError:
raise ValueError("时间格式应为 YYYY-MM-DD HH:MM")
oh_s = area.open_hour_start or "00:00"
oh_e = area.open_hour_end or "23:59"
t_s = dt_start.strftime("%H:%M")
t_e = dt_end.strftime("%H:%M")
if t_s < oh_s or t_e > oh_e:
raise ValueError(f"申请时段须在开放时段 {oh_s}{oh_e}")
if area.max_minutes:
used_min = int((dt_end - dt_start).total_seconds() // 60)
if used_min > area.max_minutes:
raise ValueError(f"单次使用时长不得超过 {area.max_minutes} 分钟")
if await _conflict_exists(db, area_id=area.id, start_at=start_at, end_at=end_at):
raise ValueError("该时段已被占用,请选择其他时间")
if not bool(data.get("agreed_commitment")):
raise ValueError("需勾选企业承诺")
now = _now()
company_id = identity.company_id
company_name = data.get("company_name") or identity.company_name
if identity.is_manager and data.get("company_id"):
# 管理方代申请:允许指定企业(校验园区内)
comp = await db.session.get(ParkCompany, data["company_id"])
if comp and comp.tenant_id == identity.tenant_id:
company_id = comp.id
company_name = data.get("company_name") or comp.name
r = IncubatorAreaReservation(
id=f"rsv_{secrets.token_hex(8)}",
tenant_id=identity.tenant_id, area_id=area.id,
area_name=area.name, area_type=area.area_type,
company_id=company_id, company_name=company_name,
user_id=identity.user_id, start_at=start_at, end_at=end_at,
attendees=_parse_int(data.get("attendees")),
purpose=str(data.get("purpose", "meeting")),
leader_name=str(data.get("leader_name", "")),
leader_phone=str(data.get("leader_phone", "")),
contact_name=str(data.get("contact_name", "")),
contact_phone=str(data.get("contact_phone", "")),
agreed_commitment=True, signer_name=str(data.get("signer_name", "")),
status="pending", created_at=now, updated_at=now,
)
db.session.add(r)
await db.session.commit()
return _rsv_row(r)
async def _get_rsv(db, identity: Identity, rsv_id: str) -> IncubatorAreaReservation:
r = await db.session.get(IncubatorAreaReservation, rsv_id)
if r is None:
raise LookupError("申请不存在")
if not identity.is_manager:
if identity.company_id != r.company_id:
raise PermissionError("仅可操作本企业申请")
elif identity.tenant_id and identity.tenant_id != r.tenant_id:
raise PermissionError("无权操作其他园区数据")
return r
async def review_reservation(db, identity: Identity, rsv_id: str, *,
action: str, comment: str = "",
record_received: bool = False,
record_safety_notified: bool = False,
record_handler: str = "") -> dict:
"""审批 + 备案:pending → approved / rejected(管理方)。"""
if not identity.is_manager:
raise PermissionError("仅管理方可审批")
if action not in ("approve", "reject"):
raise ValueError("action 仅支持 approve/reject")
r = await _get_rsv(db, identity, rsv_id)
if r.status != "pending":
raise ValueError("仅待审批申请可处理")
now = _now()
r.status = "approved" if action == "approve" else "rejected"
r.reviewer_id = identity.user_id
r.review_comment = comment
r.reviewed_at = now
if action == "approve":
# 备案(对齐登记表"园区管理办公室备案")
r.record_received = bool(record_received)
r.record_safety_notified = bool(record_safety_notified)
r.record_handler = str(record_handler or identity.user_id)
r.record_at = now
r.updated_at = now
await db.session.commit()
return _rsv_row(r)
async def cancel_reservation(db, identity: Identity, rsv_id: str) -> dict:
"""取消:发起人=本企业可取消;管理方可任意取消。pending/approved 可取消。"""
r = await _get_rsv(db, identity, rsv_id)
if r.status not in ("pending", "approved"):
raise ValueError("当前状态不可取消")
if not identity.is_manager and r.user_id != identity.user_id:
raise PermissionError("仅发起人可取消本申请")
r.status = "cancelled"
r.updated_at = _now()
await db.session.commit()
return _rsv_row(r)
async def finish_reservation(db, identity: Identity, rsv_id: str) -> dict:
"""标记使用结束:approved → finished(管理方)。"""
if not identity.is_manager:
raise PermissionError("仅管理方可标记结束")
r = await _get_rsv(db, identity, rsv_id)
if r.status != "approved":
raise ValueError("仅已通过申请可标记结束")
r.status = "finished"
r.updated_at = _now()
await db.session.commit()
return _rsv_row(r)
# ===========================================================================
# 园区设置(开放区域说明 / 申请注意事项 / 月报说明)
# ===========================================================================
def _setting_row(r: IncubatorSetting | None) -> dict:
return {
"reservation_notice": (r.reservation_notice if r else ""),
"report_notice": (r.report_notice if r else ""),
"open_summary": (r.open_summary if r else ""),
"updated_by": (r.updated_by if r else ""),
"updated_at": (r.updated_at if r else ""),
}
async def get_settings(db, identity: Identity) -> dict:
"""园区级设置:管理方与提交方都可读(管理方见空则给默认引导文案)。"""
require_identity(identity)
r = (await db.session.execute(
select(IncubatorSetting).where(
IncubatorSetting.tenant_id == identity.tenant_id)
)).scalar_one_or_none()
return _setting_row(r)
async def update_settings(db, identity: Identity, data: dict) -> dict:
"""更新园区级设置(管理方)。"""
if not identity.is_manager:
raise PermissionError("仅管理方可维护园区设置")
r = (await db.session.execute(
select(IncubatorSetting).where(
IncubatorSetting.tenant_id == identity.tenant_id)
)).scalar_one_or_none()
if r is None:
r = IncubatorSetting(id=f"set_{secrets.token_hex(8)}",
tenant_id=identity.tenant_id)
db.session.add(r)
if "reservation_notice" in data and data["reservation_notice"] is not None:
r.reservation_notice = str(data["reservation_notice"])
if "report_notice" in data and data["report_notice"] is not None:
r.report_notice = str(data["report_notice"])
if "open_summary" in data and data["open_summary"] is not None:
r.open_summary = str(data["open_summary"])
r.updated_by = identity.user_id
r.updated_at = _now()
await db.session.commit()
return _setting_row(r)
# ===========================================================================
# 企业基本信息 / 绑定成员(表单自动识别填入)
# ===========================================================================
async def get_company_profile(db, identity: Identity) -> dict:
"""提交方企业资料:名称/区域/单元/法人/联系人/地址/行业等(申请与月报自动带出)。
管理方不绑定企业 → 返回空 profile(管理方代申请时需手动指定)。
"""
require_identity(identity)
if not identity.company_id:
return {"company_id": "", "company_name": identity.company_name,
"zone": "", "room": "", "industry": "",
"legal_person": "", "legal_phone": "",
"contact_phone": "", "contact_name": "",
"address": "", "registered_capital": "",
"company_type": "", "founder": "", "founded_at": "",
"employees": None, "credit_code": ""}
comp = await db.session.get(ParkCompany, identity.company_id)
if comp is None:
return {"company_id": identity.company_id, "company_name": identity.company_name,
"zone": "", "room": "", "industry": "",
"legal_person": "", "legal_phone": "",
"contact_phone": "", "contact_name": "",
"address": "", "registered_capital": "",
"company_type": "", "founder": "", "founded_at": "",
"employees": None, "credit_code": ""}
# 统一信用代码:ParkCompany 无该字段,尝试从该企业最近一次月报带出
credit_code = ""
latest = (await db.session.execute(
select(IncubatorMonthlyReport).where(
IncubatorMonthlyReport.company_id == comp.id,
IncubatorMonthlyReport.credit_code != "",
).order_by(IncubatorMonthlyReport.report_month.desc()).limit(1)
)).scalar_one_or_none()
if latest is not None:
credit_code = latest.credit_code
return {
"company_id": comp.id, "company_name": comp.name,
"zone": comp.zone, "room": comp.room, "industry": comp.industry,
"legal_person": comp.legal_person, "legal_phone": comp.legal_phone,
"contact_phone": comp.contact_phone, "contact_name": comp.founder or "",
"address": comp.address, "registered_capital": comp.registered_capital,
"company_type": comp.company_type, "founder": comp.founder,
"founded_at": comp.founded_at, "employees": comp.employees,
"credit_code": credit_code,
}
async def list_company_members(db, identity: Identity) -> dict:
"""企业绑定成员列表(联系人自动填入用):姓名/手机/职务/是否管理员。"""
require_identity(identity)
if not identity.company_id:
return {"items": [], "total": 0}
rows = (await db.session.execute(
select(CompanyMember).where(
CompanyMember.company_id == identity.company_id,
CompanyMember.status == "active",
)
)).scalars().all()
items = []
for m in rows:
u = await db.session.get(User, m.user_id)
items.append({
"user_id": m.user_id,
"name": (u.nickname if u else "") or (u.username if u else ""),
"phone": (u.phone if u else ""),
"account": (u.account if u else ""),
"is_admin": bool(m.is_admin),
"member_type": m.member_type,
})
return {"items": items, "total": len(items)}
# ===========================================================================
# 企业列表(数据大屏 / 下拉选择)
# ===========================================================================
async def list_companies(db, identity: Identity) -> dict:
"""在园企业列表:管理方=全园区 active;提交方=本企业。
字段对齐入园企业信息,供数据大屏下拉 / 报表筛选。
"""
require_identity(identity)
q = select(ParkCompany).where(ParkCompany.tenant_id == identity.tenant_id)
if not identity.is_manager:
if not identity.company_id:
return {"items": [], "total": 0}
q = q.where(ParkCompany.id == identity.company_id)
q = q.where(ParkCompany.status == "active")
rows = (await db.session.execute(q.order_by(ParkCompany.created_at))).scalars().all()
items = []
for c in rows:
members = (await db.session.execute(
select(CompanyMember).where(CompanyMember.company_id == c.id,
CompanyMember.status == "active")
)).scalars().all()
items.append({
"id": c.id, "name": c.name, "zone": c.zone, "room": c.room,
"industry": c.industry, "founder": c.founder,
"legal_person": c.legal_person, "legal_phone": c.legal_phone,
"contact_phone": c.contact_phone, "employees": c.employees,
"emp_total": c.emp_total, "emp_grad": c.emp_grad,
"registered_capital": c.registered_capital,
"address": c.address, "founded_at": c.founded_at,
"member_count": len(members),
"created_at": c.created_at,
})
return {"items": items, "total": len(items)}
# ===========================================================================
# 数据大屏统计(本月 / 总计 / 单企业)
# ===========================================================================
async def incubator_stats(db, identity: Identity, *, dim: str = "month",
month: str = "", company_id: str = "") -> dict:
"""数据大屏统计。
dim:
- month 按月统计(默认本月,可用 month 覆盖)
- total 全部累计
- company 单企业(company_id 必填)
管理方=全园区范围;提交方自动收敛到本企业(dim 强制 company)。
"""
require_identity(identity)
tid = identity.tenant_id
if not identity.is_manager:
dim = "company"
company_id = identity.company_id or company_id
# —— 在园企业 ——
comp_q = select(ParkCompany).where(ParkCompany.tenant_id == tid,
ParkCompany.status == "active")
if dim == "company" and company_id:
comp_q = comp_q.where(ParkCompany.id == company_id)
companies = (await db.session.execute(comp_q)).scalars().all()
comp_ids = [c.id for c in companies]
comp_id = company_id if (dim == "company" and company_id in comp_ids) else ""
# —— 月度报告(按 dim 过滤) ——
rep_q = select(IncubatorMonthlyReport).where(
IncubatorMonthlyReport.tenant_id == tid)
if dim == "month":
m = month or datetime.now().strftime("%Y-%m")
rep_q = rep_q.where(IncubatorMonthlyReport.report_month == m)
elif dim == "company" and comp_ids:
rep_q = rep_q.where(IncubatorMonthlyReport.company_id.in_(comp_ids))
reports = (await db.session.execute(rep_q)).scalars().all()
# —— 共享区域使用申请(按 dim 过滤) ——
rsv_q = select(IncubatorAreaReservation).where(
IncubatorAreaReservation.tenant_id == tid)
if dim == "company" and comp_ids:
rsv_q = rsv_q.where(IncubatorAreaReservation.company_id.in_(comp_ids))
reservations = (await db.session.execute(rsv_q)).scalars().all()
# —— 共享区域资源 ——
areas = (await db.session.execute(
select(IncubatorSharedArea).where(IncubatorSharedArea.tenant_id == tid)
)).scalars().all()
def _sum(attr, rows=reports):
return round(sum(getattr(r, attr) or 0 for r in rows), 2)
submitted = [r for r in reports if r.status in ("submitted", "approved", "rejected")]
approved_reports = [r for r in reports if r.status == "approved"]
pending_reports = [r for r in reports if r.status == "submitted"]
def _cnt(status):
return len([r for r in reservations if r.status == status])
report_total = len(reports)
submit_rate = round(
(len(submitted) / len(companies) * 100 if len(companies) else 0), 1)
# —— 近 12 月趋势(month/total 维度) ——
trend = []
if dim in ("month", "total"):
by_month = {}
for r in reports:
by_month.setdefault(r.report_month, []).append(r)
months = sorted(by_month.keys())[-12:]
for m in months:
rs = by_month[m]
trend.append({
"month": m,
"reports": len([x for x in rs if x.status != "draft"]),
"revenue": round(sum((x.month_revenue_wan or 0) for x in rs), 2),
"jobs": int(sum((x.jobs_created or 0) for x in rs)),
})
# 单企业维度:趋势按该企业近 12 月
if dim == "company":
by_month = {}
for r in reports:
by_month.setdefault(r.report_month, []).append(r)
for m in sorted(by_month.keys())[-12:]:
rs = by_month[m]
trend.append({
"month": m, "reports": len([x for x in rs if x.status != "draft"]),
"revenue": round(sum((x.month_revenue_wan or 0) for x in rs), 2),
"jobs": int(sum((x.jobs_created or 0) for x in rs)),
})
return {
"dim": dim,
"month": month or (datetime.now().strftime("%Y-%m") if dim == "month" else ""),
"company_id": comp_id,
"company_name": next((c.name for c in companies if c.id == comp_id), ""),
"companies": {"total": len(companies),
"active": len([c for c in companies if c.status == "active"])},
"reports": {
"total": report_total,
"submitted": len(submitted),
"approved": len(approved_reports),
"pending": len(pending_reports),
"rejected": len([r for r in reports if r.status == "rejected"]),
"submit_rate": submit_rate,
},
"finance": {
"month_revenue_wan": _sum("month_revenue_wan"),
"avg_year_revenue_wan": _sum("avg_year_revenue_wan"),
"month_gross_profit_wan": _sum("month_gross_profit_wan"),
"month_tax_wan": _sum("month_tax_wan"),
"avg_year_tax_wan": _sum("avg_year_tax_wan"),
"rd_invest_wan": _sum("rd_invest_wan"),
"loan_startup_wan": _sum("loan_startup_wan"),
"loan_yunling_wan": _sum("loan_yunling_wan"),
"contest_award_wan": _sum("contest_award_wan"),
"patents": int(_sum("patents")),
"jobs_created": int(_sum("jobs_created")),
},
"reservations": {
"total": len(reservations),
"pending": _cnt("pending"),
"approved": _cnt("approved"),
"rejected": _cnt("rejected"),
"cancelled": _cnt("cancelled"),
"finished": _cnt("finished"),
"usage_rate": round((_cnt("approved") + _cnt("finished")) / len(reservations) * 100 if reservations else 0, 1),
},
"areas": {
"total": len(areas),
"enabled": len([a for a in areas if a.enabled]),
"maintenance": len([a for a in areas if a.status == "maintenance"]),
"closed": len([a for a in areas if a.status == "closed"]),
},
"trend": trend,
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
}
# ===========================================================================
# 共享区域时段分布(表格化展示各时段占用)
# ===========================================================================
async def area_schedule(db, identity: Identity, area_id: str, *, days: int = 7) -> dict:
"""区域未来 N 天 × 小时 的时段占用分布(表格)。
只统计 pending/approved/finished 的有效占用;cancelled/rejected 释放时段。
返回 columns=日期列表、rows=时段行、grid=二维占用(含企业名/状态)。
"""
require_identity(identity)
r = await db.session.get(IncubatorSharedArea, area_id)
if r is None:
raise LookupError("共享区域不存在")
if r.tenant_id != identity.tenant_id:
raise PermissionError("无权访问其他园区数据")
days = max(1, min(int(days or 7), 31))
from datetime import date, timedelta
today = date.today()
dates = [today + timedelta(days=i) for i in range(days)]
date_strs = [d.strftime("%Y-%m-%d") for d in dates]
# 时段 slot:从 open_hour_start 到 open_hour_end,按小时
def _hour(hhmm):
try:
hh, mm = hhmm.split(":")
return int(hh) + (int(mm) / 60.0)
except Exception:
return 9.0
hs, he = _hour(r.open_hour_start or "09:00"), _hour(r.open_hour_end or "18:00")
hours = []
h = int(hs)
while h < he:
hours.append(f"{h:02d}:00")
h += 1
if not hours:
hours = ["09:00", "10:00", "11:00", "14:00", "15:00", "16:00", "17:00"]
# 有效申请
q = select(IncubatorAreaReservation).where(
IncubatorAreaReservation.area_id == area_id,
IncubatorAreaReservation.status.in_(("pending", "approved", "finished")),
IncubatorAreaReservation.end_at >= date_strs[0],
)
rs = (await db.session.execute(q)).scalars().all()
def _dt(v):
try:
return datetime.strptime(v, "%Y-%m-%d %H:%M")
except Exception:
return None
# 将申请切分到 日期×小时 slot
grid = {}
for rs_ in rs:
s, e = _dt(rs_.start_at), _dt(rs_.end_at)
if not s or not e:
continue
for d in dates:
ds = datetime(d.year, d.month, d.day, 0, 0)
de = datetime(d.year, d.month, d.day, 23, 59)
o_s = max(s, ds)
o_e = min(e, de)
if o_s < o_e:
dkey = d.strftime("%Y-%m-%d")
for hh in hours:
hh_dt = datetime(d.year, d.month, d.day, int(hh[:2]), 0)
hh_end = hh_dt + timedelta(hours=1)
if hh_dt < o_e and o_s < hh_end:
grid.setdefault(dkey, {})[hh] = {
"company_name": rs_.company_name,
"status": rs_.status,
"rsv_id": rs_.id,
}
return {
"area_id": r.id, "area_name": r.name,
"open_hour_start": r.open_hour_start, "open_hour_end": r.open_hour_end,
"dates": date_strs,
"hours": hours,
"grid": grid,
"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,
}