86c6d24b17
统一修正3处支付相关小程序码的 page 参数(之前误写为主包 pages/ 路径, 扫码后微信找不到页面→白屏): - app/pay/service.py: 算力充值支付 → pages-extra/pay/index - app/market/service.py: 市场购买支付 → pages-extra/pay/index - app/training/main.py: 活动报名支付 → pages-extra/pay-qr/index 加上之前修复的 scan-login(auth.py + wechat.py 默认参数), 后端所有 get_wxacode 调用的 page 路径现已全部正确。
1084 lines
47 KiB
Python
1084 lines
47 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""创业园运营子应用(app/incubator)—— 月度报告 / 共享区域 / 使用申请。
|
||
|
||
业务域:
|
||
- 月度运行情况报告(对齐《入驻创业企业(项目)运行情况统计表》)
|
||
- 共享区域资源(共享直播间 / 会议室)
|
||
- 共享区域使用申请(对齐《共享区域使用登记表》)
|
||
|
||
身份语义(与《昆明大学生创业园运营系统(PawApp)设计方案》一致):
|
||
- 管理方:carrier 且归属本园区(park_tenants.operator_user_id=user.id),
|
||
或具备权限码 menu:park_incubator / incubator:manage —— 可看全园区、审核/管理。
|
||
- 提交方:园区内入驻企业成员(company_members 关联 park_companies,tenant_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,
|
||
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 = ""):
|
||
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
|
||
|
||
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,
|
||
}
|
||
|
||
|
||
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)
|
||
|
||
# —— 无权限(非本园区账号) ——
|
||
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:MM;max_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"),
|
||
}
|
||
|