From e2b7421bc48df70728ecf0fa28713154eede80ed Mon Sep 17 00:00:00 2001 From: Du Wenbo Date: Fri, 3 Apr 2026 22:06:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20enterprise-level=20enhancement=20?= =?UTF-8?q?=E2=80=94=2012=20modules=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New modules: - Energy Quota Management (定额管理) - Cost/Expense Analysis with TOU pricing (费用分析) - Sub-item Energy Analysis (分项分析) - EV Charging Station Management (充电桩管理) — 8 models, 6 pages - Enhanced Energy Analysis — loss, YoY, MoM comparison - Alarm Analytics — trends, MTTR, top devices, rule toggle - Maintenance & Work Orders (运维管理) — inspections, repair orders, duty - Data Query Module (数据查询) - Equipment Topology (设备拓扑) - Management System (管理体系) — regulations, standards, processes Infrastructure: - Redis caching layer with decorator - Redis Streams data ingestion buffer - Hourly/daily/monthly aggregation engine - Rate limiting & request ID middleware - 6 Alembic migrations (003-008), 21 new tables - Extended seed data for all modules Stats: 120+ API routes, 12 pages, 27 tabs, 37 database tables Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/launch.json | 8 +- .gitignore | 3 + backend/alembic/env.py | 6 +- .../alembic/versions/003_energy_categories.py | 37 + .../alembic/versions/004_charging_tables.py | 168 ++++ backend/alembic/versions/005_quota_tables.py | 53 ++ .../alembic/versions/006_pricing_tables.py | 48 ++ .../versions/007_maintenance_tables.py | 88 +++ .../alembic/versions/008_management_tables.py | 79 ++ backend/app/api/router.py | 7 +- backend/app/api/v1/alarms.py | 174 ++++- backend/app/api/v1/charging.py | 716 ++++++++++++++++++ backend/app/api/v1/cost.py | 279 +++++++ backend/app/api/v1/devices.py | 80 ++ backend/app/api/v1/energy.py | 464 +++++++++++- backend/app/api/v1/maintenance.py | 489 ++++++++++++ backend/app/api/v1/management.py | 385 ++++++++++ backend/app/api/v1/quota.py | 192 +++++ backend/app/collectors/queue.py | 185 +++++ backend/app/core/cache.py | 148 ++++ backend/app/core/config.py | 6 + backend/app/core/middleware.py | 86 +++ backend/app/main.py | 67 +- backend/app/models/__init__.py | 18 +- backend/app/models/charging.py | 145 ++++ backend/app/models/device.py | 1 + backend/app/models/energy.py | 14 + backend/app/models/maintenance.py | 69 ++ backend/app/models/management.py | 60 ++ backend/app/models/pricing.py | 33 + backend/app/models/quota.py | 38 + backend/app/services/aggregation.py | 291 +++++++ backend/app/services/cost_calculator.py | 261 +++++++ backend/app/services/quota_checker.py | 124 +++ backend/requirements.txt | 2 +- docs/create_pptx.cjs | 317 ++++++++ docs/天普EMS开发任务分配_晨会.pptx | Bin 0 -> 306418 bytes frontend/src/App.tsx | 10 + frontend/src/i18n/locales/en.json | 7 +- frontend/src/i18n/locales/zh.json | 7 +- frontend/src/layouts/MainLayout.tsx | 8 +- frontend/src/pages/Alarms/index.tsx | 176 ++++- frontend/src/pages/Analysis/CostAnalysis.tsx | 245 ++++++ frontend/src/pages/Analysis/LossAnalysis.tsx | 107 +++ frontend/src/pages/Analysis/MomAnalysis.tsx | 130 ++++ .../src/pages/Analysis/SubitemAnalysis.tsx | 222 ++++++ frontend/src/pages/Analysis/YoyAnalysis.tsx | 108 +++ frontend/src/pages/Analysis/index.tsx | 10 + frontend/src/pages/Charging/Dashboard.tsx | 169 +++++ frontend/src/pages/Charging/Orders.tsx | 201 +++++ frontend/src/pages/Charging/Piles.tsx | 203 +++++ frontend/src/pages/Charging/Pricing.tsx | 193 +++++ frontend/src/pages/Charging/Stations.tsx | 180 +++++ frontend/src/pages/Charging/index.tsx | 23 + frontend/src/pages/DataQuery/index.tsx | 365 +++++++++ frontend/src/pages/Devices/Topology.tsx | 186 +++++ frontend/src/pages/Maintenance/index.tsx | 399 ++++++++++ frontend/src/pages/Management/index.tsx | 524 +++++++++++++ frontend/src/pages/Quota/index.tsx | 263 +++++++ frontend/src/services/api.ts | 135 ++++ frontend/vite.config.ts | 2 +- scripts/seed_data.py | 430 +++++++++++ 62 files changed, 9422 insertions(+), 22 deletions(-) create mode 100644 backend/alembic/versions/003_energy_categories.py create mode 100644 backend/alembic/versions/004_charging_tables.py create mode 100644 backend/alembic/versions/005_quota_tables.py create mode 100644 backend/alembic/versions/006_pricing_tables.py create mode 100644 backend/alembic/versions/007_maintenance_tables.py create mode 100644 backend/alembic/versions/008_management_tables.py create mode 100644 backend/app/api/v1/charging.py create mode 100644 backend/app/api/v1/cost.py create mode 100644 backend/app/api/v1/maintenance.py create mode 100644 backend/app/api/v1/management.py create mode 100644 backend/app/api/v1/quota.py create mode 100644 backend/app/collectors/queue.py create mode 100644 backend/app/core/cache.py create mode 100644 backend/app/core/middleware.py create mode 100644 backend/app/models/charging.py create mode 100644 backend/app/models/maintenance.py create mode 100644 backend/app/models/management.py create mode 100644 backend/app/models/pricing.py create mode 100644 backend/app/models/quota.py create mode 100644 backend/app/services/aggregation.py create mode 100644 backend/app/services/cost_calculator.py create mode 100644 backend/app/services/quota_checker.py create mode 100644 docs/create_pptx.cjs create mode 100644 docs/天普EMS开发任务分配_晨会.pptx create mode 100644 frontend/src/pages/Analysis/CostAnalysis.tsx create mode 100644 frontend/src/pages/Analysis/LossAnalysis.tsx create mode 100644 frontend/src/pages/Analysis/MomAnalysis.tsx create mode 100644 frontend/src/pages/Analysis/SubitemAnalysis.tsx create mode 100644 frontend/src/pages/Analysis/YoyAnalysis.tsx create mode 100644 frontend/src/pages/Charging/Dashboard.tsx create mode 100644 frontend/src/pages/Charging/Orders.tsx create mode 100644 frontend/src/pages/Charging/Piles.tsx create mode 100644 frontend/src/pages/Charging/Pricing.tsx create mode 100644 frontend/src/pages/Charging/Stations.tsx create mode 100644 frontend/src/pages/Charging/index.tsx create mode 100644 frontend/src/pages/DataQuery/index.tsx create mode 100644 frontend/src/pages/Devices/Topology.tsx create mode 100644 frontend/src/pages/Maintenance/index.tsx create mode 100644 frontend/src/pages/Management/index.tsx create mode 100644 frontend/src/pages/Quota/index.tsx diff --git a/.claude/launch.json b/.claude/launch.json index bd21054..adb5dfd 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,15 +4,15 @@ { "name": "frontend", "runtimeExecutable": "npm", - "runtimeArgs": ["run", "dev", "--", "--force"], - "port": 3000, + "runtimeArgs": ["run", "dev", "--", "--force", "--port", "3002"], + "port": 3002, "cwd": "frontend" }, { "name": "backend", "runtimeExecutable": "uvicorn", - "runtimeArgs": ["app.main:app", "--port", "8000"], - "port": 8000, + "runtimeArgs": ["app.main:app", "--port", "8088", "--reload"], + "port": 8088, "cwd": "backend" } ] diff --git a/.gitignore b/.gitignore index 5c4a609..a61e37b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ __pycache__/ *.pyc .env.local +.env +backend/.env node_modules/ dist/ .next/ @@ -10,3 +12,4 @@ venv/ *.log .DS_Store *.db +backend/reports/ diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 1a683fe..7df504c 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -34,7 +34,11 @@ def run_migrations_online(): poolclass=pool.NullPool, ) with connectable.connect() as connection: - context.configure(connection=connection, target_metadata=target_metadata) + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + ) with context.begin_transaction(): context.run_migrations() diff --git a/backend/alembic/versions/003_energy_categories.py b/backend/alembic/versions/003_energy_categories.py new file mode 100644 index 0000000..649a534 --- /dev/null +++ b/backend/alembic/versions/003_energy_categories.py @@ -0,0 +1,37 @@ +"""Add energy_categories table and devices.category_id + +Revision ID: 003_energy_categories +Revises: 002_system_settings +Create Date: 2026-04-03 +""" +from alembic import op +import sqlalchemy as sa + +revision = "003_energy_categories" +down_revision = "002_system_settings" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- energy_categories --- + op.create_table( + "energy_categories", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("code", sa.String(50), unique=True, nullable=False), + sa.Column("parent_id", sa.Integer, sa.ForeignKey("energy_categories.id")), + sa.Column("sort_order", sa.Integer, default=0), + sa.Column("icon", sa.String(100)), + sa.Column("color", sa.String(20)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # Add category_id column to devices table (batch mode for SQLite compat) + with op.batch_alter_table("devices") as batch_op: + batch_op.add_column(sa.Column("category_id", sa.Integer, nullable=True)) + + +def downgrade() -> None: + op.drop_column("devices", "category_id") + op.drop_table("energy_categories") diff --git a/backend/alembic/versions/004_charging_tables.py b/backend/alembic/versions/004_charging_tables.py new file mode 100644 index 0000000..48c4f26 --- /dev/null +++ b/backend/alembic/versions/004_charging_tables.py @@ -0,0 +1,168 @@ +"""Add charging tables + +Revision ID: 004_charging +Revises: 003_energy_categories +Create Date: 2026-04-03 +""" +from alembic import op +import sqlalchemy as sa + +revision = "004_charging" +down_revision = "003_energy_categories" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- charging_merchants --- + op.create_table( + "charging_merchants", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("contact_person", sa.String(100)), + sa.Column("phone", sa.String(20)), + sa.Column("email", sa.String(100)), + sa.Column("address", sa.String(500)), + sa.Column("business_license", sa.String(100)), + sa.Column("status", sa.String(20), default="active"), + sa.Column("settlement_type", sa.String(20)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- charging_brands --- + op.create_table( + "charging_brands", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("brand_name", sa.String(100), nullable=False), + sa.Column("logo_url", sa.String(500)), + sa.Column("country", sa.String(50)), + sa.Column("description", sa.Text), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- charging_stations --- + op.create_table( + "charging_stations", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("merchant_id", sa.Integer, sa.ForeignKey("charging_merchants.id")), + sa.Column("type", sa.String(50)), + sa.Column("address", sa.String(500)), + sa.Column("latitude", sa.Float), + sa.Column("longitude", sa.Float), + sa.Column("price", sa.Float), + sa.Column("activity", sa.Text), + sa.Column("status", sa.String(20), default="active"), + sa.Column("total_piles", sa.Integer, default=0), + sa.Column("available_piles", sa.Integer, default=0), + sa.Column("total_power_kw", sa.Float, default=0), + sa.Column("photo_url", sa.String(500)), + sa.Column("operating_hours", sa.String(100)), + sa.Column("created_by", sa.Integer, sa.ForeignKey("users.id")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- charging_piles --- + op.create_table( + "charging_piles", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("station_id", sa.Integer, sa.ForeignKey("charging_stations.id"), nullable=False), + sa.Column("encoding", sa.String(100), unique=True), + sa.Column("name", sa.String(200)), + sa.Column("type", sa.String(50)), + sa.Column("brand", sa.String(100)), + sa.Column("model", sa.String(100)), + sa.Column("rated_power_kw", sa.Float), + sa.Column("connector_type", sa.String(50)), + sa.Column("status", sa.String(20), default="active"), + sa.Column("work_status", sa.String(20), default="offline"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- charging_price_strategies --- + op.create_table( + "charging_price_strategies", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("strategy_name", sa.String(200), nullable=False), + sa.Column("station_id", sa.Integer, sa.ForeignKey("charging_stations.id")), + sa.Column("bill_model", sa.String(20)), + sa.Column("description", sa.Text), + sa.Column("status", sa.String(20), default="inactive"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- charging_price_params --- + op.create_table( + "charging_price_params", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("strategy_id", sa.Integer, sa.ForeignKey("charging_price_strategies.id"), nullable=False), + sa.Column("start_time", sa.String(10), nullable=False), + sa.Column("end_time", sa.String(10), nullable=False), + sa.Column("period_mark", sa.String(20)), + sa.Column("elec_price", sa.Float, nullable=False), + sa.Column("service_price", sa.Float, default=0), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- charging_orders --- + op.create_table( + "charging_orders", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("order_no", sa.String(50), unique=True, nullable=False), + sa.Column("user_id", sa.Integer), + sa.Column("user_name", sa.String(100)), + sa.Column("phone", sa.String(20)), + sa.Column("station_id", sa.Integer, sa.ForeignKey("charging_stations.id")), + sa.Column("station_name", sa.String(200)), + sa.Column("pile_id", sa.Integer, sa.ForeignKey("charging_piles.id")), + sa.Column("pile_name", sa.String(200)), + sa.Column("start_time", sa.DateTime(timezone=True)), + sa.Column("end_time", sa.DateTime(timezone=True)), + sa.Column("car_no", sa.String(20)), + sa.Column("car_vin", sa.String(50)), + sa.Column("charge_method", sa.String(20)), + sa.Column("settle_type", sa.String(20)), + sa.Column("pay_type", sa.String(20)), + sa.Column("settle_time", sa.DateTime(timezone=True)), + sa.Column("settle_price", sa.Float), + sa.Column("paid_price", sa.Float), + sa.Column("discount_amt", sa.Float, default=0), + sa.Column("elec_amt", sa.Float), + sa.Column("serve_amt", sa.Float), + sa.Column("order_status", sa.String(20), default="charging"), + sa.Column("charge_duration", sa.Integer), + sa.Column("energy", sa.Float), + sa.Column("start_soc", sa.Float), + sa.Column("end_soc", sa.Float), + sa.Column("abno_cause", sa.Text), + sa.Column("order_source", sa.String(20)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- occupancy_orders --- + op.create_table( + "occupancy_orders", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("order_id", sa.Integer, sa.ForeignKey("charging_orders.id")), + sa.Column("pile_id", sa.Integer, sa.ForeignKey("charging_piles.id")), + sa.Column("start_time", sa.DateTime(timezone=True)), + sa.Column("end_time", sa.DateTime(timezone=True)), + sa.Column("occupancy_fee", sa.Float, default=0), + sa.Column("status", sa.String(20), default="active"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("occupancy_orders") + op.drop_table("charging_orders") + op.drop_table("charging_price_params") + op.drop_table("charging_price_strategies") + op.drop_table("charging_piles") + op.drop_table("charging_stations") + op.drop_table("charging_brands") + op.drop_table("charging_merchants") diff --git a/backend/alembic/versions/005_quota_tables.py b/backend/alembic/versions/005_quota_tables.py new file mode 100644 index 0000000..01fce87 --- /dev/null +++ b/backend/alembic/versions/005_quota_tables.py @@ -0,0 +1,53 @@ +"""Add quota tables + +Revision ID: 005_quota +Revises: 004_charging +Create Date: 2026-04-03 +""" +from alembic import op +import sqlalchemy as sa + +revision = "005_quota" +down_revision = "004_charging" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- energy_quotas --- + op.create_table( + "energy_quotas", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("target_type", sa.String(50), nullable=False), + sa.Column("target_id", sa.Integer, nullable=False), + sa.Column("energy_type", sa.String(50), nullable=False), + sa.Column("period", sa.String(20), nullable=False), + sa.Column("quota_value", sa.Float, nullable=False), + sa.Column("unit", sa.String(20), default="kWh"), + sa.Column("warning_threshold_pct", sa.Float, default=80), + sa.Column("alert_threshold_pct", sa.Float, default=95), + sa.Column("is_active", sa.Boolean, default=True), + sa.Column("created_by", sa.Integer, sa.ForeignKey("users.id")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- quota_usage --- + op.create_table( + "quota_usage", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("quota_id", sa.Integer, sa.ForeignKey("energy_quotas.id"), nullable=False), + sa.Column("period_start", sa.DateTime(timezone=True), nullable=False), + sa.Column("period_end", sa.DateTime(timezone=True), nullable=False), + sa.Column("actual_value", sa.Float, default=0), + sa.Column("quota_value", sa.Float, nullable=False), + sa.Column("usage_rate_pct", sa.Float, default=0), + sa.Column("status", sa.String(20), default="normal"), + sa.Column("calculated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("quota_usage") + op.drop_table("energy_quotas") diff --git a/backend/alembic/versions/006_pricing_tables.py b/backend/alembic/versions/006_pricing_tables.py new file mode 100644 index 0000000..c1cb4ef --- /dev/null +++ b/backend/alembic/versions/006_pricing_tables.py @@ -0,0 +1,48 @@ +"""Add pricing tables + +Revision ID: 006_pricing +Revises: 005_quota +Create Date: 2026-04-03 +""" +from alembic import op +import sqlalchemy as sa + +revision = "006_pricing" +down_revision = "005_quota" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- electricity_pricing --- + op.create_table( + "electricity_pricing", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("energy_type", sa.String(50), default="electricity"), + sa.Column("pricing_type", sa.String(20), nullable=False), + sa.Column("effective_from", sa.DateTime(timezone=True)), + sa.Column("effective_to", sa.DateTime(timezone=True)), + sa.Column("is_active", sa.Boolean, default=True), + sa.Column("created_by", sa.Integer, sa.ForeignKey("users.id")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- pricing_periods --- + op.create_table( + "pricing_periods", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("pricing_id", sa.Integer, sa.ForeignKey("electricity_pricing.id"), nullable=False), + sa.Column("period_name", sa.String(50), nullable=False), + sa.Column("start_time", sa.String(10), nullable=False), + sa.Column("end_time", sa.String(10), nullable=False), + sa.Column("price_per_unit", sa.Float, nullable=False), + sa.Column("applicable_months", sa.JSON), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("pricing_periods") + op.drop_table("electricity_pricing") diff --git a/backend/alembic/versions/007_maintenance_tables.py b/backend/alembic/versions/007_maintenance_tables.py new file mode 100644 index 0000000..d79fc55 --- /dev/null +++ b/backend/alembic/versions/007_maintenance_tables.py @@ -0,0 +1,88 @@ +"""Add maintenance tables + +Revision ID: 007_maintenance +Revises: 006_pricing +Create Date: 2026-04-03 +""" +from alembic import op +import sqlalchemy as sa + +revision = "007_maintenance" +down_revision = "006_pricing" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- inspection_plans --- + op.create_table( + "inspection_plans", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("description", sa.Text), + sa.Column("device_group_id", sa.Integer, sa.ForeignKey("device_groups.id")), + sa.Column("device_ids", sa.JSON), + sa.Column("schedule_type", sa.String(20)), + sa.Column("schedule_cron", sa.String(100)), + sa.Column("inspector_id", sa.Integer, sa.ForeignKey("users.id")), + sa.Column("checklist", sa.JSON), + sa.Column("is_active", sa.Boolean, default=True), + sa.Column("next_run_at", sa.DateTime(timezone=True)), + sa.Column("created_by", sa.Integer, sa.ForeignKey("users.id")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- inspection_records --- + op.create_table( + "inspection_records", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("plan_id", sa.Integer, sa.ForeignKey("inspection_plans.id"), nullable=False), + sa.Column("inspector_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False), + sa.Column("status", sa.String(20), default="pending"), + sa.Column("findings", sa.JSON), + sa.Column("started_at", sa.DateTime(timezone=True)), + sa.Column("completed_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- repair_orders --- + op.create_table( + "repair_orders", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("code", sa.String(50), unique=True, nullable=False), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("description", sa.Text), + sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id")), + sa.Column("alarm_event_id", sa.Integer, sa.ForeignKey("alarm_events.id")), + sa.Column("priority", sa.String(20), default="medium"), + sa.Column("status", sa.String(20), default="open"), + sa.Column("assigned_to", sa.Integer, sa.ForeignKey("users.id")), + sa.Column("resolution", sa.Text), + sa.Column("cost_estimate", sa.Float), + sa.Column("actual_cost", sa.Float), + sa.Column("created_by", sa.Integer, sa.ForeignKey("users.id")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("assigned_at", sa.DateTime(timezone=True)), + sa.Column("completed_at", sa.DateTime(timezone=True)), + sa.Column("closed_at", sa.DateTime(timezone=True)), + ) + + # --- duty_schedules --- + op.create_table( + "duty_schedules", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False), + sa.Column("duty_date", sa.DateTime(timezone=True), nullable=False), + sa.Column("shift", sa.String(20)), + sa.Column("area_id", sa.Integer, sa.ForeignKey("device_groups.id")), + sa.Column("notes", sa.Text), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("duty_schedules") + op.drop_table("repair_orders") + op.drop_table("inspection_records") + op.drop_table("inspection_plans") diff --git a/backend/alembic/versions/008_management_tables.py b/backend/alembic/versions/008_management_tables.py new file mode 100644 index 0000000..201ae40 --- /dev/null +++ b/backend/alembic/versions/008_management_tables.py @@ -0,0 +1,79 @@ +"""Add management tables + +Revision ID: 008_management +Revises: 007_maintenance +Create Date: 2026-04-03 +""" +from alembic import op +import sqlalchemy as sa + +revision = "008_management" +down_revision = "007_maintenance" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- regulations --- + op.create_table( + "regulations", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("category", sa.String(50)), + sa.Column("content", sa.Text), + sa.Column("effective_date", sa.DateTime(timezone=True)), + sa.Column("status", sa.String(20), default="active"), + sa.Column("attachment_url", sa.String(500)), + sa.Column("created_by", sa.Integer, sa.ForeignKey("users.id")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- standards --- + op.create_table( + "standards", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("code", sa.String(100)), + sa.Column("type", sa.String(50)), + sa.Column("description", sa.Text), + sa.Column("compliance_status", sa.String(20), default="pending"), + sa.Column("review_date", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- process_docs --- + op.create_table( + "process_docs", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("category", sa.String(50)), + sa.Column("content", sa.Text), + sa.Column("version", sa.String(20), default="1.0"), + sa.Column("approved_by", sa.String(100)), + sa.Column("effective_date", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + # --- emergency_plans --- + op.create_table( + "emergency_plans", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("scenario", sa.String(100)), + sa.Column("steps", sa.JSON), + sa.Column("responsible_person", sa.String(100)), + sa.Column("review_date", sa.DateTime(timezone=True)), + sa.Column("is_active", sa.Boolean, default=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("emergency_plans") + op.drop_table("process_docs") + op.drop_table("standards") + op.drop_table("regulations") diff --git a/backend/app/api/router.py b/backend/app/api/router.py index aad9ae6..c14af84 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket, audit, settings +from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket, audit, settings, charging, quota, cost, maintenance, management api_router = APIRouter(prefix="/api/v1") @@ -16,3 +16,8 @@ api_router.include_router(collectors.router) api_router.include_router(websocket.router) api_router.include_router(audit.router) api_router.include_router(settings.router) +api_router.include_router(charging.router) +api_router.include_router(quota.router) +api_router.include_router(cost.router) +api_router.include_router(maintenance.router) +api_router.include_router(management.router) diff --git a/backend/app/api/v1/alarms.py b/backend/app/api/v1/alarms.py index aaf6722..18d77fc 100644 --- a/backend/app/api/v1/alarms.py +++ b/backend/app/api/v1/alarms.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_ +from sqlalchemy import select, func, and_, String from datetime import datetime, timezone from pydantic import BaseModel from app.core.database import get_db @@ -139,6 +139,178 @@ async def alarm_stats(db: AsyncSession = Depends(get_db), user: User = Depends(g return stats +@router.get("/analytics") +async def get_alarm_analytics( + start_date: str | None = None, + end_date: str | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """告警分析 - Alarm trends and patterns""" + from datetime import timedelta + if not end_date: + end_dt = datetime.now(timezone.utc) + else: + end_dt = datetime.fromisoformat(end_date) + if not start_date: + start_dt = end_dt - timedelta(days=30) + else: + start_dt = datetime.fromisoformat(start_date) + + # Daily alarm count by severity + from app.core.config import get_settings + settings = get_settings() + if settings.is_sqlite: + date_col = func.strftime('%Y-%m-%d', AlarmEvent.triggered_at).label('date') + else: + date_col = func.date_trunc('day', AlarmEvent.triggered_at).cast(String).label('date') + + query = select( + date_col, + AlarmEvent.severity, + func.count(AlarmEvent.id).label('count'), + ).where( + and_(AlarmEvent.triggered_at >= start_dt, AlarmEvent.triggered_at <= end_dt) + ).group_by('date', AlarmEvent.severity).order_by('date') + + result = await db.execute(query) + rows = result.all() + + daily_map: dict[str, dict] = {} + totals = {"critical": 0, "major": 0, "warning": 0} + for date_val, severity, count in rows: + d = str(date_val)[:10] + if d not in daily_map: + daily_map[d] = {"date": d, "critical": 0, "major": 0, "warning": 0} + daily_map[d][severity] = count + if severity in totals: + totals[severity] += count + + return { + "daily_trend": list(daily_map.values()), + "totals": totals, + } + + +@router.get("/top-devices") +async def get_top_alarm_devices( + limit: int = 10, + start_date: str | None = None, + end_date: str | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """告警设备排名 - Devices with most alarms""" + from app.models.device import Device + + query = select( + AlarmEvent.device_id, + Device.name.label('device_name'), + func.count(AlarmEvent.id).label('alarm_count'), + func.max(AlarmEvent.triggered_at).label('last_alarm_time'), + ).join(Device, AlarmEvent.device_id == Device.id) + + if start_date: + query = query.where(AlarmEvent.triggered_at >= start_date) + if end_date: + query = query.where(AlarmEvent.triggered_at <= end_date) + + query = query.group_by(AlarmEvent.device_id, Device.name).order_by( + func.count(AlarmEvent.id).desc() + ).limit(limit) + + result = await db.execute(query) + return [{ + "device_id": r.device_id, + "device_name": r.device_name, + "alarm_count": r.alarm_count, + "last_alarm_time": str(r.last_alarm_time) if r.last_alarm_time else None, + } for r in result.all()] + + +@router.get("/mttr") +async def get_alarm_mttr( + start_date: str | None = None, + end_date: str | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """平均修复时间 - Mean Time To Resolve by severity""" + from app.core.config import get_settings + settings = get_settings() + + base_query = select(AlarmEvent).where( + and_(AlarmEvent.status == "resolved", AlarmEvent.resolved_at.isnot(None)) + ) + if start_date: + base_query = base_query.where(AlarmEvent.triggered_at >= start_date) + if end_date: + base_query = base_query.where(AlarmEvent.triggered_at <= end_date) + + result = await db.execute(base_query) + events = result.scalars().all() + + mttr_data: dict[str, dict] = {} + for e in events: + if not e.resolved_at or not e.triggered_at: + continue + hours = (e.resolved_at - e.triggered_at).total_seconds() / 3600 + sev = e.severity + if sev not in mttr_data: + mttr_data[sev] = {"total_hours": 0, "count": 0} + mttr_data[sev]["total_hours"] += hours + mttr_data[sev]["count"] += 1 + + return { + sev: { + "avg_hours": round(d["total_hours"] / d["count"], 2) if d["count"] > 0 else 0, + "count": d["count"], + } + for sev, d in mttr_data.items() + } + + +@router.put("/rules/{rule_id}/toggle") +async def toggle_rule( + rule_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + """快速启用/禁用规则""" + result = await db.execute(select(AlarmRule).where(AlarmRule.id == rule_id)) + rule = result.scalar_one_or_none() + if not rule: + raise HTTPException(status_code=404, detail="规则不存在") + rule.is_active = not rule.is_active + return {"id": rule.id, "is_active": rule.is_active} + + +@router.get("/rules/{rule_id}/history") +async def get_rule_history( + rule_id: int, + page: int = 1, + page_size: int = 20, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """规则触发历史""" + query = select(AlarmEvent).where(AlarmEvent.rule_id == rule_id) + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(AlarmEvent.triggered_at.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return { + "total": total, + "items": [{ + "id": e.id, "device_id": e.device_id, "severity": e.severity, + "title": e.title, "value": e.value, "threshold": e.threshold, + "status": e.status, "triggered_at": str(e.triggered_at), + "resolved_at": str(e.resolved_at) if e.resolved_at else None, + } for e in result.scalars().all()] + } + + def _rule_to_dict(r: AlarmRule) -> dict: return { "id": r.id, "name": r.name, "device_id": r.device_id, "device_type": r.device_type, diff --git a/backend/app/api/v1/charging.py b/backend/app/api/v1/charging.py new file mode 100644 index 0000000..0d92018 --- /dev/null +++ b/backend/app/api/v1/charging.py @@ -0,0 +1,716 @@ +from datetime import datetime, timedelta, timezone +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ +from pydantic import BaseModel +from app.core.database import get_db +from app.core.deps import get_current_user, require_roles +from app.models.charging import ( + ChargingStation, ChargingPile, ChargingPriceStrategy, ChargingPriceParam, + ChargingOrder, OccupancyOrder, ChargingBrand, ChargingMerchant, +) +from app.models.user import User +from app.services.audit import log_audit + +router = APIRouter(prefix="/charging", tags=["充电管理"]) + + +# ─── Pydantic Schemas ─────────────────────────────────────────────── + +class StationCreate(BaseModel): + name: str + merchant_id: int | None = None + type: str | None = None + address: str | None = None + latitude: float | None = None + longitude: float | None = None + price: float | None = None + activity: str | None = None + status: str = "active" + total_piles: int = 0 + available_piles: int = 0 + total_power_kw: float = 0 + photo_url: str | None = None + operating_hours: str | None = None + + +class StationUpdate(BaseModel): + name: str | None = None + merchant_id: int | None = None + type: str | None = None + address: str | None = None + latitude: float | None = None + longitude: float | None = None + price: float | None = None + activity: str | None = None + status: str | None = None + total_piles: int | None = None + available_piles: int | None = None + total_power_kw: float | None = None + photo_url: str | None = None + operating_hours: str | None = None + + +class PileCreate(BaseModel): + station_id: int + encoding: str + name: str | None = None + type: str | None = None + brand: str | None = None + model: str | None = None + rated_power_kw: float | None = None + connector_type: str | None = None + status: str = "active" + work_status: str = "offline" + + +class PileUpdate(BaseModel): + station_id: int | None = None + encoding: str | None = None + name: str | None = None + type: str | None = None + brand: str | None = None + model: str | None = None + rated_power_kw: float | None = None + connector_type: str | None = None + status: str | None = None + work_status: str | None = None + + +class PriceParamCreate(BaseModel): + start_time: str + end_time: str + period_mark: str | None = None + elec_price: float + service_price: float = 0 + + +class PriceStrategyCreate(BaseModel): + strategy_name: str + station_id: int | None = None + bill_model: str | None = None + description: str | None = None + status: str = "inactive" + params: list[PriceParamCreate] = [] + + +class PriceStrategyUpdate(BaseModel): + strategy_name: str | None = None + station_id: int | None = None + bill_model: str | None = None + description: str | None = None + status: str | None = None + params: list[PriceParamCreate] | None = None + + +class MerchantCreate(BaseModel): + name: str + contact_person: str | None = None + phone: str | None = None + email: str | None = None + address: str | None = None + business_license: str | None = None + status: str = "active" + settlement_type: str | None = None + + +class BrandCreate(BaseModel): + brand_name: str + logo_url: str | None = None + country: str | None = None + description: str | None = None + + +# ─── Station Endpoints ─────────────────────────────────────────────── + +@router.get("/stations") +async def list_stations( + status: str | None = None, + type: str | None = None, + merchant_id: int | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(ChargingStation) + if status: + query = query.where(ChargingStation.status == status) + if type: + query = query.where(ChargingStation.type == type) + if merchant_id: + query = query.where(ChargingStation.merchant_id == merchant_id) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(ChargingStation.id.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + stations = result.scalars().all() + return {"total": total, "items": [_station_to_dict(s) for s in stations]} + + +@router.post("/stations") +async def create_station( + data: StationCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + station = ChargingStation(**data.model_dump(), created_by=user.id) + db.add(station) + await db.flush() + await log_audit(db, user.id, "create", "charging", detail=f"创建充电站 {data.name}") + return _station_to_dict(station) + + +@router.put("/stations/{station_id}") +async def update_station( + station_id: int, + data: StationUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(ChargingStation).where(ChargingStation.id == station_id)) + station = result.scalar_one_or_none() + if not station: + raise HTTPException(status_code=404, detail="充电站不存在") + updates = data.model_dump(exclude_unset=True) + for k, v in updates.items(): + setattr(station, k, v) + await log_audit(db, user.id, "update", "charging", detail=f"更新充电站 {station.name}") + return _station_to_dict(station) + + +@router.delete("/stations/{station_id}") +async def delete_station( + station_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(ChargingStation).where(ChargingStation.id == station_id)) + station = result.scalar_one_or_none() + if not station: + raise HTTPException(status_code=404, detail="充电站不存在") + station.status = "disabled" + await log_audit(db, user.id, "delete", "charging", detail=f"禁用充电站 {station.name}") + return {"message": "已禁用"} + + +@router.get("/stations/{station_id}/piles") +async def list_station_piles( + station_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute( + select(ChargingPile).where(ChargingPile.station_id == station_id).order_by(ChargingPile.id) + ) + return [_pile_to_dict(p) for p in result.scalars().all()] + + +# ─── Pile Endpoints ────────────────────────────────────────────────── + +@router.get("/piles") +async def list_piles( + station_id: int | None = None, + status: str | None = None, + work_status: str | None = None, + type: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(ChargingPile) + if station_id: + query = query.where(ChargingPile.station_id == station_id) + if status: + query = query.where(ChargingPile.status == status) + if work_status: + query = query.where(ChargingPile.work_status == work_status) + if type: + query = query.where(ChargingPile.type == type) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(ChargingPile.id.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + piles = result.scalars().all() + return {"total": total, "items": [_pile_to_dict(p) for p in piles]} + + +@router.post("/piles") +async def create_pile( + data: PileCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + pile = ChargingPile(**data.model_dump()) + db.add(pile) + await db.flush() + await log_audit(db, user.id, "create", "charging", detail=f"创建充电桩 {data.encoding}") + return _pile_to_dict(pile) + + +@router.put("/piles/{pile_id}") +async def update_pile( + pile_id: int, + data: PileUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(ChargingPile).where(ChargingPile.id == pile_id)) + pile = result.scalar_one_or_none() + if not pile: + raise HTTPException(status_code=404, detail="充电桩不存在") + updates = data.model_dump(exclude_unset=True) + for k, v in updates.items(): + setattr(pile, k, v) + await log_audit(db, user.id, "update", "charging", detail=f"更新充电桩 {pile.encoding}") + return _pile_to_dict(pile) + + +@router.delete("/piles/{pile_id}") +async def delete_pile( + pile_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(ChargingPile).where(ChargingPile.id == pile_id)) + pile = result.scalar_one_or_none() + if not pile: + raise HTTPException(status_code=404, detail="充电桩不存在") + pile.status = "disabled" + await log_audit(db, user.id, "delete", "charging", detail=f"禁用充电桩 {pile.encoding}") + return {"message": "已禁用"} + + +# ─── Pricing Endpoints ─────────────────────────────────────────────── + +@router.get("/pricing") +async def list_pricing( + station_id: int | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(ChargingPriceStrategy) + if station_id: + query = query.where(ChargingPriceStrategy.station_id == station_id) + result = await db.execute(query.order_by(ChargingPriceStrategy.id.desc())) + strategies = result.scalars().all() + + items = [] + for s in strategies: + params_q = await db.execute( + select(ChargingPriceParam).where(ChargingPriceParam.strategy_id == s.id).order_by(ChargingPriceParam.start_time) + ) + params = [_param_to_dict(p) for p in params_q.scalars().all()] + d = _strategy_to_dict(s) + d["params"] = params + items.append(d) + return items + + +@router.post("/pricing") +async def create_pricing( + data: PriceStrategyCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + strategy = ChargingPriceStrategy( + strategy_name=data.strategy_name, + station_id=data.station_id, + bill_model=data.bill_model, + description=data.description, + status=data.status, + ) + db.add(strategy) + await db.flush() + + for p in data.params: + param = ChargingPriceParam(strategy_id=strategy.id, **p.model_dump()) + db.add(param) + await db.flush() + + await log_audit(db, user.id, "create", "charging", detail=f"创建计费策略 {data.strategy_name}") + return _strategy_to_dict(strategy) + + +@router.put("/pricing/{strategy_id}") +async def update_pricing( + strategy_id: int, + data: PriceStrategyUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(ChargingPriceStrategy).where(ChargingPriceStrategy.id == strategy_id)) + strategy = result.scalar_one_or_none() + if not strategy: + raise HTTPException(status_code=404, detail="计费策略不存在") + + updates = data.model_dump(exclude_unset=True, exclude={"params"}) + for k, v in updates.items(): + setattr(strategy, k, v) + + if data.params is not None: + # Delete old params and recreate + old_params = await db.execute( + select(ChargingPriceParam).where(ChargingPriceParam.strategy_id == strategy_id) + ) + for old in old_params.scalars().all(): + await db.delete(old) + await db.flush() + + for p in data.params: + param = ChargingPriceParam(strategy_id=strategy_id, **p.model_dump()) + db.add(param) + await db.flush() + + await log_audit(db, user.id, "update", "charging", detail=f"更新计费策略 {strategy.strategy_name}") + return _strategy_to_dict(strategy) + + +@router.delete("/pricing/{strategy_id}") +async def delete_pricing( + strategy_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(ChargingPriceStrategy).where(ChargingPriceStrategy.id == strategy_id)) + strategy = result.scalar_one_or_none() + if not strategy: + raise HTTPException(status_code=404, detail="计费策略不存在") + strategy.status = "inactive" + await log_audit(db, user.id, "delete", "charging", detail=f"停用计费策略 {strategy.strategy_name}") + return {"message": "已停用"} + + +# ─── Order Endpoints ───────────────────────────────────────────────── + +@router.get("/orders") +async def list_orders( + order_status: str | None = None, + station_id: int | None = None, + start_date: str | None = None, + end_date: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(ChargingOrder) + if order_status: + query = query.where(ChargingOrder.order_status == order_status) + if station_id: + query = query.where(ChargingOrder.station_id == station_id) + if start_date: + query = query.where(ChargingOrder.created_at >= start_date) + if end_date: + query = query.where(ChargingOrder.created_at <= end_date) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(ChargingOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + orders = result.scalars().all() + return {"total": total, "items": [_order_to_dict(o) for o in orders]} + + +@router.get("/orders/realtime") +async def realtime_orders( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute( + select(ChargingOrder).where(ChargingOrder.order_status == "charging").order_by(ChargingOrder.start_time.desc()) + ) + return [_order_to_dict(o) for o in result.scalars().all()] + + +@router.get("/orders/abnormal") +async def abnormal_orders( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(ChargingOrder).where(ChargingOrder.order_status.in_(["failed", "refunded"])) + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(ChargingOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return {"total": total, "items": [_order_to_dict(o) for o in result.scalars().all()]} + + +@router.get("/orders/{order_id}") +async def get_order( + order_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute(select(ChargingOrder).where(ChargingOrder.id == order_id)) + order = result.scalar_one_or_none() + if not order: + raise HTTPException(status_code=404, detail="订单不存在") + return _order_to_dict(order) + + +@router.post("/orders/{order_id}/settle") +async def settle_order( + order_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(ChargingOrder).where(ChargingOrder.id == order_id)) + order = result.scalar_one_or_none() + if not order: + raise HTTPException(status_code=404, detail="订单不存在") + order.settle_type = "manual" + order.settle_time = datetime.now(timezone.utc) + order.order_status = "completed" + await log_audit(db, user.id, "update", "charging", detail=f"手动结算订单 {order.order_no}") + return {"message": "已结算"} + + +# ─── Dashboard ─────────────────────────────────────────────────────── + +@router.get("/dashboard") +async def charging_dashboard( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + now = datetime.now(timezone.utc) + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + # Total revenue (completed orders) + rev_q = await db.execute( + select(func.sum(ChargingOrder.paid_price)).where(ChargingOrder.order_status == "completed") + ) + total_revenue = rev_q.scalar() or 0 + + # Total energy delivered + energy_q = await db.execute( + select(func.sum(ChargingOrder.energy)).where(ChargingOrder.order_status == "completed") + ) + total_energy = energy_q.scalar() or 0 + + # Active sessions + active_q = await db.execute( + select(func.count(ChargingOrder.id)).where(ChargingOrder.order_status == "charging") + ) + active_sessions = active_q.scalar() or 0 + + # Utilization rate: charging piles / total active piles + total_piles_q = await db.execute( + select(func.count(ChargingPile.id)).where(ChargingPile.status == "active") + ) + total_piles = total_piles_q.scalar() or 0 + charging_piles_q = await db.execute( + select(func.count(ChargingPile.id)).where(ChargingPile.work_status == "charging") + ) + charging_piles = charging_piles_q.scalar() or 0 + utilization_rate = round(charging_piles / total_piles * 100, 1) if total_piles > 0 else 0 + + # Revenue trend (last 30 days) + thirty_days_ago = now - timedelta(days=30) + trend_q = await db.execute( + select( + func.date(ChargingOrder.created_at).label("date"), + func.sum(ChargingOrder.paid_price).label("revenue"), + func.sum(ChargingOrder.energy).label("energy"), + ).where( + and_(ChargingOrder.order_status == "completed", ChargingOrder.created_at >= thirty_days_ago) + ).group_by(func.date(ChargingOrder.created_at)).order_by(func.date(ChargingOrder.created_at)) + ) + revenue_trend = [{"date": str(r[0]), "revenue": round(r[1] or 0, 2), "energy": round(r[2] or 0, 2)} for r in trend_q.all()] + + # Station ranking by revenue + ranking_q = await db.execute( + select( + ChargingOrder.station_name, + func.sum(ChargingOrder.paid_price).label("revenue"), + func.count(ChargingOrder.id).label("orders"), + ).where(ChargingOrder.order_status == "completed") + .group_by(ChargingOrder.station_name) + .order_by(func.sum(ChargingOrder.paid_price).desc()) + .limit(10) + ) + station_ranking = [{"station": r[0] or "未知", "revenue": round(r[1] or 0, 2), "orders": r[2]} for r in ranking_q.all()] + + # Pile status distribution + pile_status_q = await db.execute( + select(ChargingPile.work_status, func.count(ChargingPile.id)) + .where(ChargingPile.status == "active") + .group_by(ChargingPile.work_status) + ) + pile_status = {row[0]: row[1] for row in pile_status_q.all()} + + return { + "total_revenue": round(total_revenue, 2), + "total_energy": round(total_energy, 2), + "active_sessions": active_sessions, + "utilization_rate": utilization_rate, + "revenue_trend": revenue_trend, + "station_ranking": station_ranking, + "pile_status": { + "idle": pile_status.get("idle", 0), + "charging": pile_status.get("charging", 0), + "fault": pile_status.get("fault", 0), + "offline": pile_status.get("offline", 0), + }, + } + + +# ─── Merchant CRUD ─────────────────────────────────────────────────── + +@router.get("/merchants") +async def list_merchants(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + result = await db.execute(select(ChargingMerchant).order_by(ChargingMerchant.id.desc())) + return [_merchant_to_dict(m) for m in result.scalars().all()] + + +@router.post("/merchants") +async def create_merchant(data: MerchantCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))): + merchant = ChargingMerchant(**data.model_dump()) + db.add(merchant) + await db.flush() + return _merchant_to_dict(merchant) + + +@router.put("/merchants/{merchant_id}") +async def update_merchant(merchant_id: int, data: MerchantCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))): + result = await db.execute(select(ChargingMerchant).where(ChargingMerchant.id == merchant_id)) + merchant = result.scalar_one_or_none() + if not merchant: + raise HTTPException(status_code=404, detail="运营商不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(merchant, k, v) + return _merchant_to_dict(merchant) + + +@router.delete("/merchants/{merchant_id}") +async def delete_merchant(merchant_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))): + result = await db.execute(select(ChargingMerchant).where(ChargingMerchant.id == merchant_id)) + merchant = result.scalar_one_or_none() + if not merchant: + raise HTTPException(status_code=404, detail="运营商不存在") + merchant.status = "disabled" + return {"message": "已禁用"} + + +# ─── Brand CRUD ────────────────────────────────────────────────────── + +@router.get("/brands") +async def list_brands(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): + result = await db.execute(select(ChargingBrand).order_by(ChargingBrand.id.desc())) + return [_brand_to_dict(b) for b in result.scalars().all()] + + +@router.post("/brands") +async def create_brand(data: BrandCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))): + brand = ChargingBrand(**data.model_dump()) + db.add(brand) + await db.flush() + return _brand_to_dict(brand) + + +@router.put("/brands/{brand_id}") +async def update_brand(brand_id: int, data: BrandCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))): + result = await db.execute(select(ChargingBrand).where(ChargingBrand.id == brand_id)) + brand = result.scalar_one_or_none() + if not brand: + raise HTTPException(status_code=404, detail="品牌不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(brand, k, v) + return _brand_to_dict(brand) + + +@router.delete("/brands/{brand_id}") +async def delete_brand(brand_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_roles("admin", "energy_manager"))): + result = await db.execute(select(ChargingBrand).where(ChargingBrand.id == brand_id)) + brand = result.scalar_one_or_none() + if not brand: + raise HTTPException(status_code=404, detail="品牌不存在") + await db.delete(brand) + return {"message": "已删除"} + + +# ─── Dict Helpers ──────────────────────────────────────────────────── + +def _station_to_dict(s: ChargingStation) -> dict: + return { + "id": s.id, "name": s.name, "merchant_id": s.merchant_id, "type": s.type, + "address": s.address, "latitude": s.latitude, "longitude": s.longitude, + "price": s.price, "activity": s.activity, "status": s.status, + "total_piles": s.total_piles, "available_piles": s.available_piles, + "total_power_kw": s.total_power_kw, "photo_url": s.photo_url, + "operating_hours": s.operating_hours, "created_by": s.created_by, + "created_at": str(s.created_at) if s.created_at else None, + } + + +def _pile_to_dict(p: ChargingPile) -> dict: + return { + "id": p.id, "station_id": p.station_id, "encoding": p.encoding, + "name": p.name, "type": p.type, "brand": p.brand, "model": p.model, + "rated_power_kw": p.rated_power_kw, "connector_type": p.connector_type, + "status": p.status, "work_status": p.work_status, + "created_at": str(p.created_at) if p.created_at else None, + } + + +def _strategy_to_dict(s: ChargingPriceStrategy) -> dict: + return { + "id": s.id, "strategy_name": s.strategy_name, "station_id": s.station_id, + "bill_model": s.bill_model, "description": s.description, "status": s.status, + "created_at": str(s.created_at) if s.created_at else None, + } + + +def _param_to_dict(p: ChargingPriceParam) -> dict: + return { + "id": p.id, "strategy_id": p.strategy_id, "start_time": p.start_time, + "end_time": p.end_time, "period_mark": p.period_mark, + "elec_price": p.elec_price, "service_price": p.service_price, + } + + +def _order_to_dict(o: ChargingOrder) -> dict: + return { + "id": o.id, "order_no": o.order_no, "user_id": o.user_id, + "user_name": o.user_name, "phone": o.phone, + "station_id": o.station_id, "station_name": o.station_name, + "pile_id": o.pile_id, "pile_name": o.pile_name, + "start_time": str(o.start_time) if o.start_time else None, + "end_time": str(o.end_time) if o.end_time else None, + "car_no": o.car_no, "car_vin": o.car_vin, + "charge_method": o.charge_method, "settle_type": o.settle_type, + "pay_type": o.pay_type, + "settle_time": str(o.settle_time) if o.settle_time else None, + "settle_price": o.settle_price, "paid_price": o.paid_price, + "discount_amt": o.discount_amt, "elec_amt": o.elec_amt, + "serve_amt": o.serve_amt, "order_status": o.order_status, + "charge_duration": o.charge_duration, "energy": o.energy, + "start_soc": o.start_soc, "end_soc": o.end_soc, + "abno_cause": o.abno_cause, "order_source": o.order_source, + "created_at": str(o.created_at) if o.created_at else None, + } + + +def _merchant_to_dict(m: ChargingMerchant) -> dict: + return { + "id": m.id, "name": m.name, "contact_person": m.contact_person, + "phone": m.phone, "email": m.email, "address": m.address, + "business_license": m.business_license, "status": m.status, + "settlement_type": m.settlement_type, + } + + +def _brand_to_dict(b: ChargingBrand) -> dict: + return { + "id": b.id, "brand_name": b.brand_name, "logo_url": b.logo_url, + "country": b.country, "description": b.description, + } diff --git a/backend/app/api/v1/cost.py b/backend/app/api/v1/cost.py new file mode 100644 index 0000000..e4b8400 --- /dev/null +++ b/backend/app/api/v1/cost.py @@ -0,0 +1,279 @@ +from datetime import datetime, timedelta, timezone +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ +from pydantic import BaseModel +from app.core.database import get_db +from app.core.deps import get_current_user +from app.models.pricing import ElectricityPricing, PricingPeriod +from app.models.energy import EnergyDailySummary +from app.models.user import User +from app.services.cost_calculator import get_cost_summary, get_cost_breakdown + +router = APIRouter(prefix="/cost", tags=["费用分析"]) + + +# ---- Schemas ---- + +class PricingPeriodCreate(BaseModel): + period_name: str + start_time: str + end_time: str + price_per_unit: float + applicable_months: list[int] | None = None + + +class PricingCreate(BaseModel): + name: str + energy_type: str = "electricity" + pricing_type: str # flat, tou, tiered + effective_from: str | None = None + effective_to: str | None = None + periods: list[PricingPeriodCreate] = [] + + +class PricingUpdate(BaseModel): + name: str | None = None + energy_type: str | None = None + pricing_type: str | None = None + effective_from: str | None = None + effective_to: str | None = None + is_active: bool | None = None + + +# ---- Pricing CRUD ---- + +@router.get("/pricing") +async def list_pricing( + energy_type: str | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取电价配置列表""" + q = select(ElectricityPricing).order_by(ElectricityPricing.created_at.desc()) + if energy_type: + q = q.where(ElectricityPricing.energy_type == energy_type) + result = await db.execute(q) + pricings = result.scalars().all() + items = [] + for p in pricings: + # Load periods + pq = await db.execute(select(PricingPeriod).where(PricingPeriod.pricing_id == p.id)) + periods = pq.scalars().all() + items.append({ + "id": p.id, "name": p.name, "energy_type": p.energy_type, + "pricing_type": p.pricing_type, "is_active": p.is_active, + "effective_from": str(p.effective_from) if p.effective_from else None, + "effective_to": str(p.effective_to) if p.effective_to else None, + "created_at": str(p.created_at), + "periods": [ + {"id": pp.id, "period_name": pp.period_name, "start_time": pp.start_time, + "end_time": pp.end_time, "price_per_unit": pp.price_per_unit, + "applicable_months": pp.applicable_months} + for pp in periods + ], + }) + return items + + +@router.post("/pricing") +async def create_pricing( + data: PricingCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """创建电价配置""" + pricing = ElectricityPricing( + name=data.name, + energy_type=data.energy_type, + pricing_type=data.pricing_type, + effective_from=datetime.fromisoformat(data.effective_from) if data.effective_from else None, + effective_to=datetime.fromisoformat(data.effective_to) if data.effective_to else None, + created_by=user.id, + ) + db.add(pricing) + await db.flush() + + for period in data.periods: + pp = PricingPeriod( + pricing_id=pricing.id, + period_name=period.period_name, + start_time=period.start_time, + end_time=period.end_time, + price_per_unit=period.price_per_unit, + applicable_months=period.applicable_months, + ) + db.add(pp) + + return {"id": pricing.id, "message": "电价配置创建成功"} + + +@router.put("/pricing/{pricing_id}") +async def update_pricing( + pricing_id: int, + data: PricingUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """更新电价配置""" + result = await db.execute(select(ElectricityPricing).where(ElectricityPricing.id == pricing_id)) + pricing = result.scalar_one_or_none() + if not pricing: + raise HTTPException(status_code=404, detail="电价配置不存在") + + if data.name is not None: + pricing.name = data.name + if data.energy_type is not None: + pricing.energy_type = data.energy_type + if data.pricing_type is not None: + pricing.pricing_type = data.pricing_type + if data.effective_from is not None: + pricing.effective_from = datetime.fromisoformat(data.effective_from) + if data.effective_to is not None: + pricing.effective_to = datetime.fromisoformat(data.effective_to) + if data.is_active is not None: + pricing.is_active = data.is_active + + return {"message": "电价配置更新成功"} + + +@router.delete("/pricing/{pricing_id}") +async def deactivate_pricing( + pricing_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """停用电价配置""" + result = await db.execute(select(ElectricityPricing).where(ElectricityPricing.id == pricing_id)) + pricing = result.scalar_one_or_none() + if not pricing: + raise HTTPException(status_code=404, detail="电价配置不存在") + pricing.is_active = False + return {"message": "电价配置已停用"} + + +# ---- Pricing Periods ---- + +@router.get("/pricing/{pricing_id}/periods") +async def list_periods( + pricing_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取电价时段列表""" + result = await db.execute(select(PricingPeriod).where(PricingPeriod.pricing_id == pricing_id)) + periods = result.scalars().all() + return [ + {"id": p.id, "period_name": p.period_name, "start_time": p.start_time, + "end_time": p.end_time, "price_per_unit": p.price_per_unit, + "applicable_months": p.applicable_months} + for p in periods + ] + + +@router.post("/pricing/{pricing_id}/periods") +async def add_period( + pricing_id: int, + data: PricingPeriodCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """添加电价时段""" + result = await db.execute(select(ElectricityPricing).where(ElectricityPricing.id == pricing_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="电价配置不存在") + + period = PricingPeriod( + pricing_id=pricing_id, + period_name=data.period_name, + start_time=data.start_time, + end_time=data.end_time, + price_per_unit=data.price_per_unit, + applicable_months=data.applicable_months, + ) + db.add(period) + await db.flush() + return {"id": period.id, "message": "时段添加成功"} + + +# ---- Cost Analysis ---- + +@router.get("/summary") +async def cost_summary( + start_date: str = Query(..., description="开始日期, e.g. 2026-01-01"), + end_date: str = Query(..., description="结束日期, e.g. 2026-03-31"), + group_by: str = Query("day", pattern="^(day|month|device)$"), + energy_type: str = Query("electricity"), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """费用汇总""" + start_dt = datetime.fromisoformat(start_date) + end_dt = datetime.fromisoformat(end_date) + return await get_cost_summary(db, start_dt, end_dt, group_by, energy_type) + + +@router.get("/comparison") +async def cost_comparison( + energy_type: str = "electricity", + period: str = Query("month", pattern="^(day|week|month|year)$"), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """费用同比环比""" + now = datetime.now(timezone.utc) + + if period == "day": + current_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + prev_start = current_start - timedelta(days=1) + yoy_start = current_start.replace(year=current_start.year - 1) + elif period == "week": + current_start = now - timedelta(days=now.weekday()) + current_start = current_start.replace(hour=0, minute=0, second=0, microsecond=0) + prev_start = current_start - timedelta(weeks=1) + yoy_start = current_start.replace(year=current_start.year - 1) + elif period == "month": + current_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + prev_start = (current_start - timedelta(days=1)).replace(day=1) + yoy_start = current_start.replace(year=current_start.year - 1) + else: # year + current_start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) + prev_start = current_start.replace(year=current_start.year - 1) + yoy_start = prev_start + + async def sum_cost(start, end): + q = select(func.sum(EnergyDailySummary.cost)).where( + and_( + EnergyDailySummary.date >= start, + EnergyDailySummary.date < end, + EnergyDailySummary.energy_type == energy_type, + ) + ) + r = await db.execute(q) + return r.scalar() or 0 + + current = await sum_cost(current_start, now) + previous = await sum_cost(prev_start, current_start) + yoy = await sum_cost(yoy_start, yoy_start.replace(year=yoy_start.year + 1)) + + return { + "current": round(current, 2), + "previous": round(previous, 2), + "yoy": round(yoy, 2), + "mom_change": round((current - previous) / previous * 100, 1) if previous else 0, + "yoy_change": round((current - yoy) / yoy * 100, 1) if yoy else 0, + } + + +@router.get("/breakdown") +async def cost_breakdown_api( + start_date: str = Query(..., description="开始日期"), + end_date: str = Query(..., description="结束日期"), + energy_type: str = Query("electricity"), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """峰谷平费用分布""" + start_dt = datetime.fromisoformat(start_date) + end_dt = datetime.fromisoformat(end_date) + return await get_cost_breakdown(db, start_dt, end_dt, energy_type) diff --git a/backend/app/api/v1/devices.py b/backend/app/api/v1/devices.py index 4397b60..655b147 100644 --- a/backend/app/api/v1/devices.py +++ b/backend/app/api/v1/devices.py @@ -116,6 +116,86 @@ async def update_device(device_id: int, data: DeviceUpdate, db: AsyncSession = D return _device_to_dict(device) +@router.get("/topology") +async def get_device_topology( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """设备拓扑树 - Full device tree with counts and status""" + # Get all groups + group_result = await db.execute(select(DeviceGroup).order_by(DeviceGroup.id)) + groups = group_result.scalars().all() + + # Get device counts and status per group + status_query = ( + select( + Device.group_id, + Device.status, + func.count(Device.id).label('cnt'), + ) + .where(Device.is_active == True) + .group_by(Device.group_id, Device.status) + ) + status_result = await db.execute(status_query) + status_rows = status_result.all() + + # Build status map: group_id -> {status: count} + group_stats: dict[int | None, dict[str, int]] = {} + for group_id, status, cnt in status_rows: + if group_id not in group_stats: + group_stats[group_id] = {} + group_stats[group_id][status] = cnt + + # Build group nodes + group_map: dict[int, dict] = {} + for g in groups: + stats = group_stats.get(g.id, {}) + device_count = sum(stats.values()) + group_map[g.id] = { + "id": g.id, + "name": g.name, + "location": g.location, + "parent_id": g.parent_id, + "children": [], + "device_count": device_count, + "online_count": stats.get("online", 0), + "offline_count": stats.get("offline", 0), + "alarm_count": stats.get("alarm", 0), + } + + # Build tree + roots = [] + for gid, node in group_map.items(): + pid = node["parent_id"] + if pid and pid in group_map: + group_map[pid]["children"].append(node) + else: + roots.append(node) + + # Propagate child counts up + def propagate(node: dict) -> tuple[int, int, int, int]: + total = node["device_count"] + online = node["online_count"] + offline = node["offline_count"] + alarm = node["alarm_count"] + for child in node["children"]: + ct, co, coff, ca = propagate(child) + total += ct + online += co + offline += coff + alarm += ca + node["total_device_count"] = total + node["total_online"] = online + node["total_offline"] = offline + node["total_alarm"] = alarm + return total, online, offline, alarm + + for root in roots: + propagate(root) + + return roots + + def _device_to_dict(d: Device) -> dict: return { "id": d.id, "name": d.name, "code": d.code, "device_type": d.device_type, diff --git a/backend/app/api/v1/energy.py b/backend/app/api/v1/energy.py index e7e28d4..21d345d 100644 --- a/backend/app/api/v1/energy.py +++ b/backend/app/api/v1/energy.py @@ -2,7 +2,7 @@ import csv import io from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, HTTPException from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, and_, text, Integer @@ -11,9 +11,10 @@ from pydantic import BaseModel from app.core.database import get_db from app.core.config import get_settings from app.core.deps import get_current_user -from app.models.energy import EnergyData, EnergyDailySummary +from app.models.energy import EnergyData, EnergyDailySummary, EnergyCategory from app.models.device import Device from app.models.user import User +from app.core.deps import require_roles router = APIRouter(prefix="/energy", tags=["能耗数据"]) @@ -83,6 +84,81 @@ async def query_history( for r in result.all()] +@router.get("/params") +async def query_electrical_params( + device_id: int = Query(..., description="设备ID"), + params: str = Query("power", description="参数列表(逗号分隔): power,voltage,current,power_factor,temperature,frequency,cop"), + start_time: str | None = None, + end_time: str | None = None, + granularity: str = Query("raw", pattern="^(raw|5min|hour|day)$"), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """电参量查询 - Query multiple electrical parameters for a device""" + param_list = [p.strip() for p in params.split(",") if p.strip()] + result_data = {} + settings = get_settings() + + for param in param_list: + base_filter = and_( + EnergyData.device_id == device_id, + EnergyData.data_type == param, + ) + conditions = [base_filter] + if start_time: + conditions.append(EnergyData.timestamp >= start_time) + if end_time: + conditions.append(EnergyData.timestamp <= end_time) + combined = and_(*conditions) + + if granularity == "raw": + query = ( + select(EnergyData.timestamp, EnergyData.value, EnergyData.unit) + .where(combined) + .order_by(EnergyData.timestamp) + .limit(5000) + ) + rows = await db.execute(query) + result_data[param] = [ + {"timestamp": str(r[0]), "value": r[1], "unit": r[2]} + for r in rows.all() + ] + else: + if granularity == "5min": + if settings.is_sqlite: + time_bucket = func.strftime('%Y-%m-%d %H:', EnergyData.timestamp).op('||')( + func.printf('%02d:00', (func.cast(func.strftime('%M', EnergyData.timestamp), Integer) / 5) * 5) + ).label('time_bucket') + else: + time_bucket = func.to_timestamp( + func.floor(func.extract('epoch', EnergyData.timestamp) / 300) * 300 + ).label('time_bucket') + elif granularity == "hour": + if settings.is_sqlite: + time_bucket = func.strftime('%Y-%m-%d %H:00:00', EnergyData.timestamp).label('time_bucket') + else: + time_bucket = func.date_trunc('hour', EnergyData.timestamp).label('time_bucket') + else: # day + if settings.is_sqlite: + time_bucket = func.strftime('%Y-%m-%d', EnergyData.timestamp).label('time_bucket') + else: + time_bucket = func.date_trunc('day', EnergyData.timestamp).label('time_bucket') + + agg_query = ( + select(time_bucket, func.avg(EnergyData.value).label('avg_value')) + .where(combined) + .group_by(text('time_bucket')) + .order_by(text('time_bucket')) + ) + rows = await db.execute(agg_query) + result_data[param] = [ + {"timestamp": str(r[0]), "value": round(r[1], 2)} + for r in rows.all() + ] + + return result_data + + @router.get("/daily-summary") async def daily_summary( start_date: str | None = None, @@ -301,3 +377,387 @@ def _export_xlsx(headers: list[str], rows: list[list], filename: str) -> Streami media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) + + +# ── Energy Category (分项能耗) ────────────────────────────────────── + +class CategoryCreate(BaseModel): + name: str + code: str + parent_id: int | None = None + sort_order: int = 0 + icon: str | None = None + color: str | None = None + + +def _category_to_dict(c: EnergyCategory) -> dict: + return { + "id": c.id, "name": c.name, "code": c.code, + "parent_id": c.parent_id, "sort_order": c.sort_order, + "icon": c.icon, "color": c.color, + "created_at": str(c.created_at) if c.created_at else None, + } + + +def _build_category_tree(items: list[dict], parent_id: int | None = None) -> list[dict]: + tree = [] + for item in items: + if item["parent_id"] == parent_id: + children = _build_category_tree(items, item["id"]) + if children: + item["children"] = children + tree.append(item) + return tree + + +@router.get("/categories") +async def list_categories( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取能耗分项类别(树结构)""" + result = await db.execute( + select(EnergyCategory).order_by(EnergyCategory.sort_order, EnergyCategory.id) + ) + items = [_category_to_dict(c) for c in result.scalars().all()] + return _build_category_tree(items) + + +@router.post("/categories") +async def create_category( + data: CategoryCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + """创建能耗分项类别""" + cat = EnergyCategory(**data.model_dump()) + db.add(cat) + await db.flush() + return _category_to_dict(cat) + + +@router.put("/categories/{cat_id}") +async def update_category( + cat_id: int, + data: CategoryCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + """更新能耗分项类别""" + result = await db.execute(select(EnergyCategory).where(EnergyCategory.id == cat_id)) + cat = result.scalar_one_or_none() + if not cat: + raise HTTPException(status_code=404, detail="分项类别不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(cat, k, v) + return _category_to_dict(cat) + + +@router.get("/by-category") +async def energy_by_category( + start_date: str | None = None, + end_date: str | None = None, + energy_type: str = "electricity", + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """按分项类别统计能耗""" + query = ( + select( + EnergyCategory.id, + EnergyCategory.name, + EnergyCategory.code, + EnergyCategory.color, + func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0).label("consumption"), + ) + .select_from(EnergyCategory) + .outerjoin(Device, Device.category_id == EnergyCategory.id) + .outerjoin( + EnergyDailySummary, + and_( + EnergyDailySummary.device_id == Device.id, + EnergyDailySummary.energy_type == energy_type, + ), + ) + ) + if start_date: + query = query.where(EnergyDailySummary.date >= start_date) + if end_date: + query = query.where(EnergyDailySummary.date <= end_date) + query = query.group_by(EnergyCategory.id, EnergyCategory.name, EnergyCategory.code, EnergyCategory.color) + result = await db.execute(query) + rows = result.all() + total = sum(r.consumption for r in rows) or 1 + return [ + { + "id": r.id, "name": r.name, "code": r.code, "color": r.color, + "consumption": round(r.consumption, 2), + "percentage": round(r.consumption / total * 100, 1), + } + for r in rows + ] + + +@router.get("/category-ranking") +async def category_ranking( + start_date: str | None = None, + end_date: str | None = None, + energy_type: str = "electricity", + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """分项能耗排名""" + query = ( + select( + EnergyCategory.name, + EnergyCategory.color, + func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0).label("consumption"), + ) + .select_from(EnergyCategory) + .outerjoin(Device, Device.category_id == EnergyCategory.id) + .outerjoin( + EnergyDailySummary, + and_( + EnergyDailySummary.device_id == Device.id, + EnergyDailySummary.energy_type == energy_type, + ), + ) + ) + if start_date: + query = query.where(EnergyDailySummary.date >= start_date) + if end_date: + query = query.where(EnergyDailySummary.date <= end_date) + query = query.group_by(EnergyCategory.name, EnergyCategory.color).order_by(text("consumption DESC")) + result = await db.execute(query) + return [{"name": r.name, "color": r.color, "consumption": round(r.consumption, 2)} for r in result.all()] + + +@router.get("/category-trend") +async def category_trend( + start_date: str | None = None, + end_date: str | None = None, + energy_type: str = "electricity", + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """分项能耗每日趋势""" + query = ( + select( + EnergyDailySummary.date, + EnergyCategory.name, + EnergyCategory.color, + func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0).label("consumption"), + ) + .select_from(EnergyDailySummary) + .join(Device, EnergyDailySummary.device_id == Device.id) + .join(EnergyCategory, Device.category_id == EnergyCategory.id) + .where(EnergyDailySummary.energy_type == energy_type) + ) + if start_date: + query = query.where(EnergyDailySummary.date >= start_date) + if end_date: + query = query.where(EnergyDailySummary.date <= end_date) + query = query.group_by(EnergyDailySummary.date, EnergyCategory.name, EnergyCategory.color) + query = query.order_by(EnergyDailySummary.date) + result = await db.execute(query) + return [ + {"date": str(r.date), "category": r.name, "color": r.color, "consumption": round(r.consumption, 2)} + for r in result.all() + ] + + +# ── Loss / YoY / MoM Analysis ───────────────────────────────────── + +from app.models.device import DeviceGroup + + +@router.get("/loss") +async def get_energy_loss( + start_date: str, + end_date: str, + energy_type: str = "electricity", + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """能耗损耗分析 - Compare parent meter vs sum of sub-meters""" + # Get all groups that have children + groups_result = await db.execute(select(DeviceGroup)) + all_groups = groups_result.scalars().all() + group_map = {g.id: g for g in all_groups} + parent_ids = {g.parent_id for g in all_groups if g.parent_id is not None} + + results = [] + for gid in parent_ids: + group = group_map.get(gid) + if not group: + continue + child_group_ids = [g.id for g in all_groups if g.parent_id == gid] + + # Parent consumption: devices directly in this group + parent_q = select(func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0)).select_from( + EnergyDailySummary + ).join(Device, EnergyDailySummary.device_id == Device.id).where( + and_( + Device.group_id == gid, + EnergyDailySummary.energy_type == energy_type, + EnergyDailySummary.date >= start_date, + EnergyDailySummary.date <= end_date, + ) + ) + parent_consumption = (await db.execute(parent_q)).scalar() or 0 + + # Children consumption: devices in child groups + if child_group_ids: + children_q = select(func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0)).select_from( + EnergyDailySummary + ).join(Device, EnergyDailySummary.device_id == Device.id).where( + and_( + Device.group_id.in_(child_group_ids), + EnergyDailySummary.energy_type == energy_type, + EnergyDailySummary.date >= start_date, + EnergyDailySummary.date <= end_date, + ) + ) + children_consumption = (await db.execute(children_q)).scalar() or 0 + else: + children_consumption = 0 + + loss = parent_consumption - children_consumption + loss_rate = (loss / parent_consumption * 100) if parent_consumption > 0 else 0 + + results.append({ + "group_name": group.name, + "parent_consumption": round(parent_consumption, 2), + "children_consumption": round(children_consumption, 2), + "loss": round(loss, 2), + "loss_rate_pct": round(loss_rate, 1), + }) + + return results + + +@router.get("/yoy") +async def get_yoy_comparison( + year: int | None = None, + energy_type: str = "electricity", + group_id: int | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """同比分析 - Current year vs previous year, month by month""" + current_year = year or datetime.now(timezone.utc).year + prev_year = current_year - 1 + settings = get_settings() + + results = [] + for month in range(1, 13): + for yr, label in [(current_year, "current_year"), (prev_year, "previous_year")]: + month_start = f"{yr}-{month:02d}-01" + if month == 12: + month_end = f"{yr + 1}-01-01" + else: + month_end = f"{yr}-{month + 1:02d}-01" + + q = select(func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0)).where( + and_( + EnergyDailySummary.energy_type == energy_type, + EnergyDailySummary.date >= month_start, + EnergyDailySummary.date < month_end, + ) + ) + if group_id: + q = q.select_from(EnergyDailySummary).join( + Device, EnergyDailySummary.device_id == Device.id + ).where(Device.group_id == group_id) + + val = (await db.execute(q)).scalar() or 0 + # Find or create month entry + existing = next((r for r in results if r["month"] == month), None) + if not existing: + existing = {"month": month, "current_year": 0, "previous_year": 0, "change_pct": 0} + results.append(existing) + existing[label] = round(val, 2) + + # Calculate change percentages + for r in results: + if r["previous_year"] > 0: + r["change_pct"] = round((r["current_year"] - r["previous_year"]) / r["previous_year"] * 100, 1) + + return results + + +@router.get("/mom") +async def get_mom_comparison( + period: str = "month", + energy_type: str = "electricity", + group_id: int | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """环比分析 - Current period vs previous period""" + now = datetime.now(timezone.utc) + + if period == "month": + current_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + prev_start = (current_start - timedelta(days=1)).replace(day=1) + # Generate daily labels + days_in_month = (now - current_start).days + 1 + prev_end = current_start + labels = [f"{i + 1}日" for i in range(31)] + elif period == "week": + current_start = (now - timedelta(days=now.weekday())).replace(hour=0, minute=0, second=0, microsecond=0) + prev_start = current_start - timedelta(weeks=1) + prev_end = current_start + labels = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] + else: # day + current_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + prev_start = current_start - timedelta(days=1) + prev_end = current_start + labels = [f"{i}:00" for i in range(24)] + + async def get_period_data(start, end): + q = select( + EnergyDailySummary.date, + func.sum(EnergyDailySummary.total_consumption).label("consumption"), + ).where( + and_( + EnergyDailySummary.energy_type == energy_type, + EnergyDailySummary.date >= str(start)[:10], + EnergyDailySummary.date < str(end)[:10], + ) + ) + if group_id: + q = q.select_from(EnergyDailySummary).join( + Device, EnergyDailySummary.device_id == Device.id + ).where(Device.group_id == group_id) + q = q.group_by(EnergyDailySummary.date).order_by(EnergyDailySummary.date) + result = await db.execute(q) + return [{"date": str(r.date), "consumption": round(r.consumption, 2)} for r in result.all()] + + current_data = await get_period_data(current_start, now) + previous_data = await get_period_data(prev_start, prev_end) + + # Build comparison items + max_len = max(len(current_data), len(previous_data), 1) + items = [] + for i in range(max_len): + cur_val = current_data[i]["consumption"] if i < len(current_data) else 0 + prev_val = previous_data[i]["consumption"] if i < len(previous_data) else 0 + change_pct = round((cur_val - prev_val) / prev_val * 100, 1) if prev_val > 0 else 0 + items.append({ + "label": labels[i] if i < len(labels) else str(i + 1), + "current_period": cur_val, + "previous_period": prev_val, + "change_pct": change_pct, + }) + + total_current = sum(d["consumption"] for d in current_data) + total_previous = sum(d["consumption"] for d in previous_data) + total_change = round((total_current - total_previous) / total_previous * 100, 1) if total_previous > 0 else 0 + + return { + "items": items, + "total_current": round(total_current, 2), + "total_previous": round(total_previous, 2), + "total_change_pct": total_change, + } diff --git a/backend/app/api/v1/maintenance.py b/backend/app/api/v1/maintenance.py new file mode 100644 index 0000000..fd6874c --- /dev/null +++ b/backend/app/api/v1/maintenance.py @@ -0,0 +1,489 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ +from pydantic import BaseModel +from app.core.database import get_db +from app.core.deps import get_current_user, require_roles +from app.models.maintenance import InspectionPlan, InspectionRecord, RepairOrder, DutySchedule +from app.models.user import User + +router = APIRouter(prefix="/maintenance", tags=["运维管理"]) + + +# ── Pydantic Schemas ──────────────────────────────────────────────── + +class PlanCreate(BaseModel): + name: str + description: str | None = None + device_group_id: int | None = None + device_ids: list[int] | None = None + schedule_type: str | None = None + schedule_cron: str | None = None + inspector_id: int | None = None + checklist: list[dict] | None = None + is_active: bool = True + next_run_at: str | None = None + + +class RecordCreate(BaseModel): + plan_id: int + inspector_id: int + status: str = "pending" + findings: list[dict] | None = None + started_at: str | None = None + + +class RecordUpdate(BaseModel): + status: str | None = None + findings: list[dict] | None = None + completed_at: str | None = None + + +class OrderCreate(BaseModel): + title: str + description: str | None = None + device_id: int | None = None + alarm_event_id: int | None = None + priority: str = "medium" + cost_estimate: float | None = None + + +class OrderUpdate(BaseModel): + title: str | None = None + description: str | None = None + priority: str | None = None + status: str | None = None + resolution: str | None = None + actual_cost: float | None = None + + +class DutyCreate(BaseModel): + user_id: int + duty_date: str + shift: str | None = None + area_id: int | None = None + notes: str | None = None + + +# ── Helpers ───────────────────────────────────────────────────────── + +def _plan_to_dict(p: InspectionPlan) -> dict: + return { + "id": p.id, "name": p.name, "description": p.description, + "device_group_id": p.device_group_id, "device_ids": p.device_ids, + "schedule_type": p.schedule_type, "schedule_cron": p.schedule_cron, + "inspector_id": p.inspector_id, "checklist": p.checklist, + "is_active": p.is_active, + "next_run_at": str(p.next_run_at) if p.next_run_at else None, + "created_by": p.created_by, + "created_at": str(p.created_at) if p.created_at else None, + "updated_at": str(p.updated_at) if p.updated_at else None, + } + + +def _record_to_dict(r: InspectionRecord) -> dict: + return { + "id": r.id, "plan_id": r.plan_id, "inspector_id": r.inspector_id, + "status": r.status, "findings": r.findings, + "started_at": str(r.started_at) if r.started_at else None, + "completed_at": str(r.completed_at) if r.completed_at else None, + "created_at": str(r.created_at) if r.created_at else None, + } + + +def _order_to_dict(o: RepairOrder) -> dict: + return { + "id": o.id, "code": o.code, "title": o.title, "description": o.description, + "device_id": o.device_id, "alarm_event_id": o.alarm_event_id, + "priority": o.priority, "status": o.status, "assigned_to": o.assigned_to, + "resolution": o.resolution, "cost_estimate": o.cost_estimate, + "actual_cost": o.actual_cost, "created_by": o.created_by, + "created_at": str(o.created_at) if o.created_at else None, + "assigned_at": str(o.assigned_at) if o.assigned_at else None, + "completed_at": str(o.completed_at) if o.completed_at else None, + "closed_at": str(o.closed_at) if o.closed_at else None, + } + + +def _duty_to_dict(d: DutySchedule) -> dict: + return { + "id": d.id, "user_id": d.user_id, + "duty_date": str(d.duty_date) if d.duty_date else None, + "shift": d.shift, "area_id": d.area_id, "notes": d.notes, + "created_at": str(d.created_at) if d.created_at else None, + } + + +def _generate_order_code() -> str: + now = datetime.now(timezone.utc) + return f"WO-{now.strftime('%Y%m%d')}-{now.strftime('%H%M%S')}" + + +# ── Inspection Plans ──────────────────────────────────────────────── + +@router.get("/plans") +async def list_plans( + is_active: bool | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(InspectionPlan).order_by(InspectionPlan.id.desc()) + if is_active is not None: + query = query.where(InspectionPlan.is_active == is_active) + result = await db.execute(query) + return [_plan_to_dict(p) for p in result.scalars().all()] + + +@router.post("/plans") +async def create_plan( + data: PlanCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + plan = InspectionPlan(**data.model_dump(exclude={"next_run_at"}), created_by=user.id) + if data.next_run_at: + plan.next_run_at = datetime.fromisoformat(data.next_run_at) + db.add(plan) + await db.flush() + return _plan_to_dict(plan) + + +@router.put("/plans/{plan_id}") +async def update_plan( + plan_id: int, + data: PlanCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(InspectionPlan).where(InspectionPlan.id == plan_id)) + plan = result.scalar_one_or_none() + if not plan: + raise HTTPException(status_code=404, detail="巡检计划不存在") + for k, v in data.model_dump(exclude_unset=True, exclude={"next_run_at"}).items(): + setattr(plan, k, v) + if data.next_run_at: + plan.next_run_at = datetime.fromisoformat(data.next_run_at) + return _plan_to_dict(plan) + + +@router.delete("/plans/{plan_id}") +async def delete_plan( + plan_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(InspectionPlan).where(InspectionPlan.id == plan_id)) + plan = result.scalar_one_or_none() + if not plan: + raise HTTPException(status_code=404, detail="巡检计划不存在") + plan.is_active = False + return {"message": "已删除"} + + +@router.post("/plans/{plan_id}/trigger") +async def trigger_plan( + plan_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + """手动触发巡检计划,生成巡检记录""" + result = await db.execute(select(InspectionPlan).where(InspectionPlan.id == plan_id)) + plan = result.scalar_one_or_none() + if not plan: + raise HTTPException(status_code=404, detail="巡检计划不存在") + record = InspectionRecord( + plan_id=plan.id, + inspector_id=plan.inspector_id or user.id, + status="pending", + ) + db.add(record) + await db.flush() + return _record_to_dict(record) + + +# ── Inspection Records ────────────────────────────────────────────── + +@router.get("/records") +async def list_records( + plan_id: int | None = None, + status: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(InspectionRecord) + if plan_id: + query = query.where(InspectionRecord.plan_id == plan_id) + if status: + query = query.where(InspectionRecord.status == status) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(InspectionRecord.id.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return { + "total": total, + "items": [_record_to_dict(r) for r in result.scalars().all()], + } + + +@router.post("/records") +async def create_record( + data: RecordCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + record = InspectionRecord(**data.model_dump(exclude={"started_at"})) + if data.started_at: + record.started_at = datetime.fromisoformat(data.started_at) + db.add(record) + await db.flush() + return _record_to_dict(record) + + +@router.put("/records/{record_id}") +async def update_record( + record_id: int, + data: RecordUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute(select(InspectionRecord).where(InspectionRecord.id == record_id)) + record = result.scalar_one_or_none() + if not record: + raise HTTPException(status_code=404, detail="巡检记录不存在") + for k, v in data.model_dump(exclude_unset=True, exclude={"completed_at"}).items(): + setattr(record, k, v) + if data.completed_at: + record.completed_at = datetime.fromisoformat(data.completed_at) + elif data.status == "completed" or data.status == "issues_found": + record.completed_at = datetime.now(timezone.utc) + return _record_to_dict(record) + + +# ── Repair Orders ─────────────────────────────────────────────────── + +@router.get("/orders") +async def list_orders( + status: str | None = None, + priority: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(RepairOrder) + if status: + query = query.where(RepairOrder.status == status) + if priority: + query = query.where(RepairOrder.priority == priority) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(RepairOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return { + "total": total, + "items": [_order_to_dict(o) for o in result.scalars().all()], + } + + +@router.post("/orders") +async def create_order( + data: OrderCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + order = RepairOrder( + **data.model_dump(), + code=_generate_order_code(), + created_by=user.id, + ) + db.add(order) + await db.flush() + return _order_to_dict(order) + + +@router.put("/orders/{order_id}") +async def update_order( + order_id: int, + data: OrderUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute(select(RepairOrder).where(RepairOrder.id == order_id)) + order = result.scalar_one_or_none() + if not order: + raise HTTPException(status_code=404, detail="工单不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(order, k, v) + return _order_to_dict(order) + + +@router.put("/orders/{order_id}/assign") +async def assign_order( + order_id: int, + assigned_to: int = Query(..., description="指派的用户ID"), + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(RepairOrder).where(RepairOrder.id == order_id)) + order = result.scalar_one_or_none() + if not order: + raise HTTPException(status_code=404, detail="工单不存在") + order.assigned_to = assigned_to + order.status = "assigned" + order.assigned_at = datetime.now(timezone.utc) + return _order_to_dict(order) + + +@router.put("/orders/{order_id}/complete") +async def complete_order( + order_id: int, + resolution: str = "", + actual_cost: float | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + result = await db.execute(select(RepairOrder).where(RepairOrder.id == order_id)) + order = result.scalar_one_or_none() + if not order: + raise HTTPException(status_code=404, detail="工单不存在") + order.status = "completed" + order.resolution = resolution + if actual_cost is not None: + order.actual_cost = actual_cost + order.completed_at = datetime.now(timezone.utc) + return _order_to_dict(order) + + +# ── Duty Schedule ─────────────────────────────────────────────────── + +@router.get("/duty") +async def list_duty( + start_date: str | None = None, + end_date: str | None = None, + user_id: int | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(DutySchedule) + if start_date: + query = query.where(DutySchedule.duty_date >= start_date) + if end_date: + query = query.where(DutySchedule.duty_date <= end_date) + if user_id: + query = query.where(DutySchedule.user_id == user_id) + query = query.order_by(DutySchedule.duty_date) + result = await db.execute(query) + return [_duty_to_dict(d) for d in result.scalars().all()] + + +@router.post("/duty") +async def create_duty( + data: DutyCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + duty = DutySchedule( + user_id=data.user_id, + duty_date=datetime.fromisoformat(data.duty_date), + shift=data.shift, + area_id=data.area_id, + notes=data.notes, + ) + db.add(duty) + await db.flush() + return _duty_to_dict(duty) + + +@router.put("/duty/{duty_id}") +async def update_duty( + duty_id: int, + data: DutyCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(DutySchedule).where(DutySchedule.id == duty_id)) + duty = result.scalar_one_or_none() + if not duty: + raise HTTPException(status_code=404, detail="值班记录不存在") + duty.user_id = data.user_id + duty.duty_date = datetime.fromisoformat(data.duty_date) + duty.shift = data.shift + duty.area_id = data.area_id + duty.notes = data.notes + return _duty_to_dict(duty) + + +@router.delete("/duty/{duty_id}") +async def delete_duty( + duty_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(DutySchedule).where(DutySchedule.id == duty_id)) + duty = result.scalar_one_or_none() + if not duty: + raise HTTPException(status_code=404, detail="值班记录不存在") + await db.delete(duty) + return {"message": "已删除"} + + +# ── Dashboard ─────────────────────────────────────────────────────── + +@router.get("/dashboard") +async def maintenance_dashboard( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + now = datetime.now(timezone.utc) + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + + # Open orders count + open_q = select(func.count()).select_from(RepairOrder).where( + RepairOrder.status.in_(["open", "assigned", "in_progress"]) + ) + open_orders = (await db.execute(open_q)).scalar() or 0 + + # Overdue: assigned but not completed, assigned_at > 7 days ago + from datetime import timedelta + overdue_cutoff = now - timedelta(days=7) + overdue_q = select(func.count()).select_from(RepairOrder).where( + and_( + RepairOrder.status.in_(["assigned", "in_progress"]), + RepairOrder.assigned_at < overdue_cutoff, + ) + ) + overdue_count = (await db.execute(overdue_q)).scalar() or 0 + + # Today's inspections + inspect_q = select(func.count()).select_from(InspectionRecord).where( + InspectionRecord.created_at >= today_start, + ) + todays_inspections = (await db.execute(inspect_q)).scalar() or 0 + + # Upcoming duties (next 7 days) + duty_end = now + timedelta(days=7) + duty_q = select(func.count()).select_from(DutySchedule).where( + and_(DutySchedule.duty_date >= today_start, DutySchedule.duty_date <= duty_end) + ) + upcoming_duties = (await db.execute(duty_q)).scalar() or 0 + + # Recent orders (latest 10) + recent_q = select(RepairOrder).order_by(RepairOrder.created_at.desc()).limit(10) + recent_result = await db.execute(recent_q) + recent_orders = [_order_to_dict(o) for o in recent_result.scalars().all()] + + return { + "open_orders": open_orders, + "overdue_count": overdue_count, + "todays_inspections": todays_inspections, + "upcoming_duties": upcoming_duties, + "recent_orders": recent_orders, + } diff --git a/backend/app/api/v1/management.py b/backend/app/api/v1/management.py new file mode 100644 index 0000000..b3f3563 --- /dev/null +++ b/backend/app/api/v1/management.py @@ -0,0 +1,385 @@ +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from pydantic import BaseModel +from app.core.database import get_db +from app.core.deps import get_current_user, require_roles +from app.models.management import Regulation, Standard, ProcessDoc, EmergencyPlan +from app.models.user import User + +router = APIRouter(prefix="/management", tags=["管理体系"]) + + +# ── Pydantic Schemas ────────────────────────────────────────────────── + +class RegulationCreate(BaseModel): + title: str + category: str | None = None + content: str | None = None + effective_date: datetime | None = None + status: str = "active" + attachment_url: str | None = None + + +class StandardCreate(BaseModel): + name: str + code: str | None = None + type: str | None = None + description: str | None = None + compliance_status: str = "pending" + review_date: datetime | None = None + + +class ProcessDocCreate(BaseModel): + title: str + category: str | None = None + content: str | None = None + version: str = "1.0" + approved_by: str | None = None + effective_date: datetime | None = None + + +class EmergencyPlanCreate(BaseModel): + title: str + scenario: str | None = None + steps: list[dict] | None = None + responsible_person: str | None = None + review_date: datetime | None = None + is_active: bool = True + + +# ── Regulations (规章制度) ──────────────────────────────────────────── + +@router.get("/regulations") +async def list_regulations( + category: str | None = None, + status: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(Regulation) + if category: + query = query.where(Regulation.category == category) + if status: + query = query.where(Regulation.status == status) + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + query = query.order_by(Regulation.id.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return { + "total": total, + "items": [_regulation_to_dict(r) for r in result.scalars().all()], + } + + +@router.post("/regulations") +async def create_regulation( + data: RegulationCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + reg = Regulation(**data.model_dump(), created_by=user.id) + db.add(reg) + await db.flush() + return _regulation_to_dict(reg) + + +@router.put("/regulations/{reg_id}") +async def update_regulation( + reg_id: int, + data: RegulationCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(Regulation).where(Regulation.id == reg_id)) + reg = result.scalar_one_or_none() + if not reg: + raise HTTPException(status_code=404, detail="规章制度不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(reg, k, v) + return _regulation_to_dict(reg) + + +@router.delete("/regulations/{reg_id}") +async def delete_regulation( + reg_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(Regulation).where(Regulation.id == reg_id)) + reg = result.scalar_one_or_none() + if not reg: + raise HTTPException(status_code=404, detail="规章制度不存在") + await db.delete(reg) + return {"message": "已删除"} + + +# ── Standards (标准规范) ────────────────────────────────────────────── + +@router.get("/standards") +async def list_standards( + type: str | None = None, + compliance_status: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(Standard) + if type: + query = query.where(Standard.type == type) + if compliance_status: + query = query.where(Standard.compliance_status == compliance_status) + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + query = query.order_by(Standard.id.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return { + "total": total, + "items": [_standard_to_dict(s) for s in result.scalars().all()], + } + + +@router.post("/standards") +async def create_standard( + data: StandardCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + std = Standard(**data.model_dump()) + db.add(std) + await db.flush() + return _standard_to_dict(std) + + +@router.put("/standards/{std_id}") +async def update_standard( + std_id: int, + data: StandardCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(Standard).where(Standard.id == std_id)) + std = result.scalar_one_or_none() + if not std: + raise HTTPException(status_code=404, detail="标准规范不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(std, k, v) + return _standard_to_dict(std) + + +@router.delete("/standards/{std_id}") +async def delete_standard( + std_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(Standard).where(Standard.id == std_id)) + std = result.scalar_one_or_none() + if not std: + raise HTTPException(status_code=404, detail="标准规范不存在") + await db.delete(std) + return {"message": "已删除"} + + +# ── Process Docs (管理流程) ─────────────────────────────────────────── + +@router.get("/process-docs") +async def list_process_docs( + category: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(ProcessDoc) + if category: + query = query.where(ProcessDoc.category == category) + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + query = query.order_by(ProcessDoc.id.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return { + "total": total, + "items": [_process_doc_to_dict(d) for d in result.scalars().all()], + } + + +@router.post("/process-docs") +async def create_process_doc( + data: ProcessDocCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + doc = ProcessDoc(**data.model_dump()) + db.add(doc) + await db.flush() + return _process_doc_to_dict(doc) + + +@router.put("/process-docs/{doc_id}") +async def update_process_doc( + doc_id: int, + data: ProcessDocCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(ProcessDoc).where(ProcessDoc.id == doc_id)) + doc = result.scalar_one_or_none() + if not doc: + raise HTTPException(status_code=404, detail="管理流程文档不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(doc, k, v) + return _process_doc_to_dict(doc) + + +@router.delete("/process-docs/{doc_id}") +async def delete_process_doc( + doc_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(ProcessDoc).where(ProcessDoc.id == doc_id)) + doc = result.scalar_one_or_none() + if not doc: + raise HTTPException(status_code=404, detail="管理流程文档不存在") + await db.delete(doc) + return {"message": "已删除"} + + +# ── Emergency Plans (应急预案) ──────────────────────────────────────── + +@router.get("/emergency-plans") +async def list_emergency_plans( + scenario: str | None = None, + is_active: bool | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + query = select(EmergencyPlan) + if scenario: + query = query.where(EmergencyPlan.scenario == scenario) + if is_active is not None: + query = query.where(EmergencyPlan.is_active == is_active) + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + query = query.order_by(EmergencyPlan.id.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return { + "total": total, + "items": [_emergency_plan_to_dict(p) for p in result.scalars().all()], + } + + +@router.post("/emergency-plans") +async def create_emergency_plan( + data: EmergencyPlanCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + plan = EmergencyPlan(**data.model_dump()) + db.add(plan) + await db.flush() + return _emergency_plan_to_dict(plan) + + +@router.put("/emergency-plans/{plan_id}") +async def update_emergency_plan( + plan_id: int, + data: EmergencyPlanCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(EmergencyPlan).where(EmergencyPlan.id == plan_id)) + plan = result.scalar_one_or_none() + if not plan: + raise HTTPException(status_code=404, detail="应急预案不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(plan, k, v) + return _emergency_plan_to_dict(plan) + + +@router.delete("/emergency-plans/{plan_id}") +async def delete_emergency_plan( + plan_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(EmergencyPlan).where(EmergencyPlan.id == plan_id)) + plan = result.scalar_one_or_none() + if not plan: + raise HTTPException(status_code=404, detail="应急预案不存在") + await db.delete(plan) + return {"message": "已删除"} + + +# ── Compliance Overview ─────────────────────────────────────────────── + +@router.get("/compliance") +async def compliance_overview( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """合规概览 - count by compliance_status for standards""" + result = await db.execute( + select(Standard.compliance_status, func.count(Standard.id)) + .group_by(Standard.compliance_status) + ) + stats = {row[0]: row[1] for row in result.all()} + return { + "compliant": stats.get("compliant", 0), + "non_compliant": stats.get("non_compliant", 0), + "pending": stats.get("pending", 0), + "in_progress": stats.get("in_progress", 0), + "total": sum(stats.values()), + } + + +# ── Serializers ─────────────────────────────────────────────────────── + +def _regulation_to_dict(r: Regulation) -> dict: + return { + "id": r.id, "title": r.title, "category": r.category, + "content": r.content, "effective_date": str(r.effective_date) if r.effective_date else None, + "status": r.status, "attachment_url": r.attachment_url, + "created_by": r.created_by, + "created_at": str(r.created_at) if r.created_at else None, + "updated_at": str(r.updated_at) if r.updated_at else None, + } + + +def _standard_to_dict(s: Standard) -> dict: + return { + "id": s.id, "name": s.name, "code": s.code, "type": s.type, + "description": s.description, "compliance_status": s.compliance_status, + "review_date": str(s.review_date) if s.review_date else None, + "created_at": str(s.created_at) if s.created_at else None, + "updated_at": str(s.updated_at) if s.updated_at else None, + } + + +def _process_doc_to_dict(d: ProcessDoc) -> dict: + return { + "id": d.id, "title": d.title, "category": d.category, + "content": d.content, "version": d.version, + "approved_by": d.approved_by, + "effective_date": str(d.effective_date) if d.effective_date else None, + "created_at": str(d.created_at) if d.created_at else None, + "updated_at": str(d.updated_at) if d.updated_at else None, + } + + +def _emergency_plan_to_dict(p: EmergencyPlan) -> dict: + return { + "id": p.id, "title": p.title, "scenario": p.scenario, + "steps": p.steps, "responsible_person": p.responsible_person, + "review_date": str(p.review_date) if p.review_date else None, + "is_active": p.is_active, + "created_at": str(p.created_at) if p.created_at else None, + "updated_at": str(p.updated_at) if p.updated_at else None, + } diff --git a/backend/app/api/v1/quota.py b/backend/app/api/v1/quota.py new file mode 100644 index 0000000..8e9a885 --- /dev/null +++ b/backend/app/api/v1/quota.py @@ -0,0 +1,192 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ +from datetime import datetime, timezone +from pydantic import BaseModel +from app.core.database import get_db +from app.core.deps import get_current_user, require_roles +from app.models.quota import EnergyQuota, QuotaUsage +from app.models.user import User + +router = APIRouter(prefix="/quota", tags=["配额管理"]) + + +class QuotaCreate(BaseModel): + name: str + target_type: str + target_id: int + energy_type: str + period: str + quota_value: float + unit: str = "kWh" + warning_threshold_pct: float = 80 + alert_threshold_pct: float = 95 + + +@router.get("") +async def list_quotas( + target_type: str | None = None, + energy_type: str | None = None, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """列出所有配额,附带当前使用率""" + query = select(EnergyQuota).where(EnergyQuota.is_active == True) + if target_type: + query = query.where(EnergyQuota.target_type == target_type) + if energy_type: + query = query.where(EnergyQuota.energy_type == energy_type) + query = query.order_by(EnergyQuota.id.desc()) + result = await db.execute(query) + quotas = result.scalars().all() + + items = [] + for q in quotas: + # 获取最新使用记录 + usage_result = await db.execute( + select(QuotaUsage) + .where(QuotaUsage.quota_id == q.id) + .order_by(QuotaUsage.calculated_at.desc()) + .limit(1) + ) + usage = usage_result.scalar_one_or_none() + items.append({ + **_quota_to_dict(q), + "current_usage": usage.actual_value if usage else 0, + "usage_rate_pct": usage.usage_rate_pct if usage else 0, + "usage_status": usage.status if usage else "normal", + }) + return items + + +@router.post("") +async def create_quota( + data: QuotaCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + quota = EnergyQuota(**data.model_dump(), created_by=user.id) + db.add(quota) + await db.flush() + return _quota_to_dict(quota) + + +@router.put("/{quota_id}") +async def update_quota( + quota_id: int, + data: QuotaCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(EnergyQuota).where(EnergyQuota.id == quota_id)) + quota = result.scalar_one_or_none() + if not quota: + raise HTTPException(status_code=404, detail="配额不存在") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(quota, k, v) + return _quota_to_dict(quota) + + +@router.delete("/{quota_id}") +async def delete_quota( + quota_id: int, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_roles("admin", "energy_manager")), +): + result = await db.execute(select(EnergyQuota).where(EnergyQuota.id == quota_id)) + quota = result.scalar_one_or_none() + if not quota: + raise HTTPException(status_code=404, detail="配额不存在") + quota.is_active = False + return {"message": "已删除"} + + +@router.get("/usage") +async def quota_usage( + target_type: str | None = None, + energy_type: str | None = None, + period: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """配额使用统计,支持筛选和分页""" + query = ( + select(QuotaUsage) + .join(EnergyQuota, QuotaUsage.quota_id == EnergyQuota.id) + .where(EnergyQuota.is_active == True) + ) + if target_type: + query = query.where(EnergyQuota.target_type == target_type) + if energy_type: + query = query.where(EnergyQuota.energy_type == energy_type) + if period: + query = query.where(EnergyQuota.period == period) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await db.execute(count_q)).scalar() + + query = query.order_by(QuotaUsage.calculated_at.desc()).offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + return { + "total": total, + "items": [{ + "id": u.id, "quota_id": u.quota_id, + "period_start": str(u.period_start), "period_end": str(u.period_end), + "actual_value": u.actual_value, "quota_value": u.quota_value, + "usage_rate_pct": u.usage_rate_pct, "status": u.status, + "calculated_at": str(u.calculated_at), + } for u in result.scalars().all()] + } + + +@router.get("/compliance") +async def quota_compliance( + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """配额合规概览:统计各状态数量""" + # 每个活跃配额的最新使用记录 + quotas_result = await db.execute( + select(EnergyQuota).where(EnergyQuota.is_active == True) + ) + quotas = quotas_result.scalars().all() + + summary = {"total": 0, "normal": 0, "warning": 0, "exceeded": 0} + details = [] + + for q in quotas: + usage_result = await db.execute( + select(QuotaUsage) + .where(QuotaUsage.quota_id == q.id) + .order_by(QuotaUsage.calculated_at.desc()) + .limit(1) + ) + usage = usage_result.scalar_one_or_none() + status = usage.status if usage else "normal" + summary["total"] += 1 + summary[status] = summary.get(status, 0) + 1 + details.append({ + "quota_id": q.id, + "name": q.name, + "target_type": q.target_type, + "energy_type": q.energy_type, + "quota_value": q.quota_value, + "actual_value": usage.actual_value if usage else 0, + "usage_rate_pct": usage.usage_rate_pct if usage else 0, + "status": status, + }) + + return {"summary": summary, "details": details} + + +def _quota_to_dict(q: EnergyQuota) -> dict: + return { + "id": q.id, "name": q.name, "target_type": q.target_type, + "target_id": q.target_id, "energy_type": q.energy_type, + "period": q.period, "quota_value": q.quota_value, "unit": q.unit, + "warning_threshold_pct": q.warning_threshold_pct, + "alert_threshold_pct": q.alert_threshold_pct, + "is_active": q.is_active, + } diff --git a/backend/app/collectors/queue.py b/backend/app/collectors/queue.py new file mode 100644 index 0000000..3b3648b --- /dev/null +++ b/backend/app/collectors/queue.py @@ -0,0 +1,185 @@ +"""Redis Streams-based data ingestion buffer for high-throughput device data.""" +import asyncio +import json +import logging +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import select + +from app.core.cache import get_redis +from app.core.config import get_settings +from app.core.database import async_session +from app.models.energy import EnergyData + +logger = logging.getLogger("ingestion.queue") + +STREAM_KEY = "ems:ingestion:stream" +CONSUMER_GROUP = "ems:ingestion:workers" +CONSUMER_NAME = "worker-1" + + +class IngestionQueue: + """Push device data into a Redis Stream for buffered ingestion.""" + + async def push( + self, + device_id: int, + data_type: str, + value: float, + unit: str, + timestamp: Optional[str] = None, + raw_data: Optional[dict] = None, + ) -> Optional[str]: + """Add a data point to the ingestion stream. + + Returns the message ID on success, None on failure. + """ + redis = await get_redis() + if not redis: + return None + try: + fields = { + "device_id": str(device_id), + "data_type": data_type, + "value": str(value), + "unit": unit, + "timestamp": timestamp or datetime.now(timezone.utc).isoformat(), + } + if raw_data: + fields["raw_data"] = json.dumps(raw_data, ensure_ascii=False, default=str) + msg_id = await redis.xadd(STREAM_KEY, fields) + return msg_id + except Exception as e: + logger.error("Failed to push to ingestion stream: %s", e) + return None + + async def consume_batch(self, count: int = 100) -> list[tuple[str, dict]]: + """Read up to `count` messages from the stream via consumer group. + + Returns list of (message_id, fields) tuples. + """ + redis = await get_redis() + if not redis: + return [] + try: + # Ensure consumer group exists + try: + await redis.xgroup_create(STREAM_KEY, CONSUMER_GROUP, id="0", mkstream=True) + except Exception: + # Group already exists + pass + + messages = await redis.xreadgroup( + CONSUMER_GROUP, + CONSUMER_NAME, + {STREAM_KEY: ">"}, + count=count, + block=1000, + ) + if not messages: + return [] + # messages format: [(stream_key, [(msg_id, fields), ...])] + return messages[0][1] + except Exception as e: + logger.error("Failed to consume from ingestion stream: %s", e) + return [] + + async def ack(self, message_ids: list[str]) -> int: + """Acknowledge processed messages. + + Returns number of successfully acknowledged messages. + """ + redis = await get_redis() + if not redis or not message_ids: + return 0 + try: + return await redis.xack(STREAM_KEY, CONSUMER_GROUP, *message_ids) + except Exception as e: + logger.error("Failed to ack messages: %s", e) + return 0 + + +class IngestionWorker: + """Background worker that drains the ingestion stream and bulk-inserts to DB.""" + + def __init__(self, batch_size: int = 100, interval: float = 2.0): + self.batch_size = batch_size + self.interval = interval + self._queue = IngestionQueue() + self._running = False + self._task: Optional[asyncio.Task] = None + + async def start(self): + """Start the background ingestion worker.""" + self._running = True + self._task = asyncio.create_task(self._run(), name="ingestion-worker") + logger.info( + "IngestionWorker started (batch_size=%d, interval=%.1fs)", + self.batch_size, + self.interval, + ) + + async def stop(self): + """Stop the ingestion worker gracefully.""" + self._running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + logger.info("IngestionWorker stopped.") + + async def _run(self): + """Main loop: consume batches from stream and insert to DB.""" + while self._running: + try: + messages = await self._queue.consume_batch(count=self.batch_size) + if messages: + await self._process_batch(messages) + else: + await asyncio.sleep(self.interval) + except asyncio.CancelledError: + raise + except Exception as e: + logger.error("IngestionWorker error: %s", e, exc_info=True) + await asyncio.sleep(self.interval) + + async def _process_batch(self, messages: list[tuple[str, dict]]): + """Parse messages and bulk-insert EnergyData rows.""" + msg_ids = [] + rows = [] + for msg_id, fields in messages: + msg_ids.append(msg_id) + try: + ts_str = fields.get("timestamp", "") + timestamp = datetime.fromisoformat(ts_str) if ts_str else datetime.now(timezone.utc) + raw = None + if "raw_data" in fields: + try: + raw = json.loads(fields["raw_data"]) + except (json.JSONDecodeError, TypeError): + raw = None + rows.append( + EnergyData( + device_id=int(fields["device_id"]), + timestamp=timestamp, + data_type=fields["data_type"], + value=float(fields["value"]), + unit=fields.get("unit", ""), + raw_data=raw, + ) + ) + except (KeyError, ValueError) as e: + logger.warning("Skipping malformed message %s: %s", msg_id, e) + + if rows: + async with async_session() as session: + session.add_all(rows) + await session.commit() + logger.debug("Bulk-inserted %d rows from ingestion stream.", len(rows)) + + # Acknowledge all messages (including malformed ones to avoid reprocessing) + if msg_ids: + await self._queue.ack(msg_ids) diff --git a/backend/app/core/cache.py b/backend/app/core/cache.py new file mode 100644 index 0000000..1d31317 --- /dev/null +++ b/backend/app/core/cache.py @@ -0,0 +1,148 @@ +"""Redis caching layer with graceful fallback when Redis is unavailable.""" +import json +import logging +from functools import wraps +from typing import Any, Optional + +import redis.asyncio as aioredis + +from app.core.config import get_settings + +logger = logging.getLogger("cache") + +_redis_pool: Optional[aioredis.Redis] = None + + +async def get_redis() -> Optional[aioredis.Redis]: + """Get or create a global Redis connection pool. + + Returns None if Redis is disabled or connection fails. + """ + global _redis_pool + settings = get_settings() + if not settings.REDIS_ENABLED: + return None + if _redis_pool is not None: + return _redis_pool + try: + _redis_pool = aioredis.from_url( + settings.REDIS_URL, + decode_responses=True, + max_connections=20, + ) + # Verify connectivity + await _redis_pool.ping() + logger.info("Redis connection established: %s", settings.REDIS_URL) + return _redis_pool + except Exception as e: + logger.warning("Redis unavailable, caching disabled: %s", e) + _redis_pool = None + return None + + +async def close_redis(): + """Close the global Redis connection pool.""" + global _redis_pool + if _redis_pool: + await _redis_pool.close() + _redis_pool = None + logger.info("Redis connection closed.") + + +class RedisCache: + """Async Redis cache with JSON serialization and graceful fallback.""" + + def __init__(self, redis_client: Optional[aioredis.Redis] = None): + self._redis = redis_client + + async def _get_client(self) -> Optional[aioredis.Redis]: + if self._redis is not None: + return self._redis + return await get_redis() + + async def get(self, key: str) -> Optional[Any]: + """Get a value from cache. Returns None on miss or error.""" + client = await self._get_client() + if not client: + return None + try: + raw = await client.get(key) + if raw is None: + return None + return json.loads(raw) + except (json.JSONDecodeError, TypeError): + return raw + except Exception as e: + logger.warning("Cache get error for key=%s: %s", key, e) + return None + + async def set(self, key: str, value: Any, ttl: int = 300) -> bool: + """Set a value in cache with TTL in seconds.""" + client = await self._get_client() + if not client: + return False + try: + serialized = json.dumps(value, ensure_ascii=False, default=str) + await client.set(key, serialized, ex=ttl) + return True + except Exception as e: + logger.warning("Cache set error for key=%s: %s", key, e) + return False + + async def delete(self, key: str) -> bool: + """Delete a key from cache.""" + client = await self._get_client() + if not client: + return False + try: + await client.delete(key) + return True + except Exception as e: + logger.warning("Cache delete error for key=%s: %s", key, e) + return False + + async def exists(self, key: str) -> bool: + """Check if a key exists in cache.""" + client = await self._get_client() + if not client: + return False + try: + return bool(await client.exists(key)) + except Exception as e: + logger.warning("Cache exists error for key=%s: %s", key, e) + return False + + +def cache_response(prefix: str, ttl_seconds: int = 300): + """Decorator to cache FastAPI endpoint responses in Redis. + + Builds cache key from prefix + sorted query params. + Falls through to the endpoint when Redis is unavailable. + """ + + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + # Build cache key from all keyword arguments + sorted_params = "&".join( + f"{k}={v}" for k, v in sorted(kwargs.items()) + if v is not None and k != "db" and k != "user" + ) + cache_key = f"{prefix}:{sorted_params}" if sorted_params else prefix + + cache = RedisCache() + # Try cache hit + cached = await cache.get(cache_key) + if cached is not None: + return cached + + # Call the actual endpoint + result = await func(*args, **kwargs) + + # Store result in cache + await cache.set(cache_key, result, ttl=ttl_seconds) + return result + + return wrapper + + return decorator diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 02a215a..c9f20a4 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -23,6 +23,12 @@ class Settings(BaseSettings): CELERY_ENABLED: bool = False # Set True when Celery worker is running USE_SIMULATOR: bool = True # True=simulator mode, False=real IoT collectors + # Infrastructure flags + TIMESCALE_ENABLED: bool = False + REDIS_ENABLED: bool = True + INGESTION_QUEUE_ENABLED: bool = False + AGGREGATION_ENABLED: bool = True + # SMTP Email settings SMTP_HOST: str = "" SMTP_PORT: int = 587 diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py new file mode 100644 index 0000000..32b2647 --- /dev/null +++ b/backend/app/core/middleware.py @@ -0,0 +1,86 @@ +"""Custom middleware for request tracking and rate limiting.""" +import logging +import time +import uuid +from typing import Optional + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse + +from app.core.config import get_settings + +logger = logging.getLogger("middleware") + + +class RequestIdMiddleware(BaseHTTPMiddleware): + """Adds X-Request-ID header to every response.""" + + async def dispatch(self, request: Request, call_next): + request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())) + request.state.request_id = request_id + response = await call_next(request) + response.headers["X-Request-ID"] = request_id + return response + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """Redis-based rate limiting middleware. + + Default: 100 requests/minute per user. + Auth endpoints: 10 requests/minute per IP. + Graceful fallback when Redis is unavailable (allows all requests). + """ + + DEFAULT_LIMIT = 100 # requests per minute + AUTH_LIMIT = 10 # requests per minute for auth endpoints + WINDOW_SECONDS = 60 + + async def dispatch(self, request: Request, call_next): + settings = get_settings() + if not settings.REDIS_ENABLED: + return await call_next(request) + + try: + from app.core.cache import get_redis + redis = await get_redis() + except Exception: + redis = None + + if not redis: + return await call_next(request) + + try: + is_auth = request.url.path.startswith("/api/v1/auth") + limit = self.AUTH_LIMIT if is_auth else self.DEFAULT_LIMIT + + if is_auth: + client_ip = request.client.host if request.client else "unknown" + key = f"rl:auth:{client_ip}" + else: + # Use user token hash or client IP for rate limiting + auth_header = request.headers.get("Authorization", "") + if auth_header: + key = f"rl:user:{hash(auth_header)}" + else: + client_ip = request.client.host if request.client else "unknown" + key = f"rl:anon:{client_ip}" + + current = await redis.incr(key) + if current == 1: + await redis.expire(key, self.WINDOW_SECONDS) + + if current > limit: + ttl = await redis.ttl(key) + return JSONResponse( + status_code=429, + content={ + "detail": "Too many requests", + "retry_after": max(ttl, 1), + }, + headers={"Retry-After": str(max(ttl, 1))}, + ) + except Exception as e: + logger.warning("Rate limiting error (allowing request): %s", e) + + return await call_next(request) diff --git a/backend/app/main.py b/backend/app/main.py index 77c80d5..6df4a6f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,26 +1,50 @@ import logging +import uuid from contextlib import asynccontextmanager from typing import Optional -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from app.api.router import api_router from app.api.v1.websocket import start_broadcast_task, stop_broadcast_task from app.core.config import get_settings +from app.core.cache import get_redis, close_redis from app.services.simulator import DataSimulator from app.services.report_scheduler import start_scheduler, stop_scheduler +from app.services.aggregation import start_aggregation_scheduler, stop_aggregation_scheduler from app.collectors.manager import CollectorManager +from app.collectors.queue import IngestionWorker settings = get_settings() simulator = DataSimulator() collector_manager: Optional[CollectorManager] = None +ingestion_worker: Optional[IngestionWorker] = None logger = logging.getLogger("app") @asynccontextmanager async def lifespan(app: FastAPI): - global collector_manager + global collector_manager, ingestion_worker + + # Initialize Redis cache + if settings.REDIS_ENABLED: + redis = await get_redis() + if redis: + logger.info("Redis cache initialized") + + # Start aggregation scheduler + if settings.AGGREGATION_ENABLED: + await start_aggregation_scheduler() + logger.info("Aggregation scheduler started") + + # Start ingestion worker + if settings.INGESTION_QUEUE_ENABLED: + ingestion_worker = IngestionWorker() + await ingestion_worker.start() + logger.info("Ingestion worker started") + if settings.USE_SIMULATOR: logger.info("Starting in SIMULATOR mode") await simulator.start() @@ -40,6 +64,20 @@ async def lifespan(app: FastAPI): await collector_manager.stop() collector_manager = None + # Stop ingestion worker + if ingestion_worker: + await ingestion_worker.stop() + ingestion_worker = None + + # Stop aggregation scheduler + if settings.AGGREGATION_ENABLED: + await stop_aggregation_scheduler() + + # Close Redis + if settings.REDIS_ENABLED: + await close_redis() + logger.info("Redis cache closed") + app = FastAPI( title="天普零碳园区智慧能源管理平台", @@ -56,6 +94,31 @@ app.add_middleware( allow_headers=["*"], ) + +@app.middleware("http") +async def request_id_middleware(request: Request, call_next): + """Add a unique X-Request-ID header to every response.""" + request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())) + request.state.request_id = request_id + response = await call_next(request) + response.headers["X-Request-ID"] = request_id + return response + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + """Global exception handler for consistent error responses.""" + request_id = getattr(request.state, "request_id", "unknown") + logger.error("Unhandled exception [request_id=%s]: %s", request_id, exc, exc_info=True) + return JSONResponse( + status_code=500, + content={ + "detail": "Internal server error", + "request_id": request_id, + }, + ) + + app.include_router(api_router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d340bc6..77d7e1a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,17 +1,31 @@ from app.models.user import User, Role, AuditLog from app.models.device import Device, DeviceGroup, DeviceType -from app.models.energy import EnergyData, EnergyDailySummary +from app.models.energy import EnergyData, EnergyDailySummary, EnergyCategory from app.models.alarm import AlarmRule, AlarmEvent from app.models.carbon import CarbonEmission, EmissionFactor from app.models.report import ReportTemplate, ReportTask from app.models.setting import SystemSetting +from app.models.charging import ( + ChargingStation, ChargingPile, ChargingPriceStrategy, ChargingPriceParam, + ChargingOrder, OccupancyOrder, ChargingBrand, ChargingMerchant, +) +from app.models.quota import EnergyQuota, QuotaUsage +from app.models.pricing import ElectricityPricing, PricingPeriod +from app.models.maintenance import InspectionPlan, InspectionRecord, RepairOrder, DutySchedule +from app.models.management import Regulation, Standard, ProcessDoc, EmergencyPlan __all__ = [ "User", "Role", "AuditLog", "Device", "DeviceGroup", "DeviceType", - "EnergyData", "EnergyDailySummary", + "EnergyData", "EnergyDailySummary", "EnergyCategory", "AlarmRule", "AlarmEvent", "CarbonEmission", "EmissionFactor", "ReportTemplate", "ReportTask", "SystemSetting", + "ChargingStation", "ChargingPile", "ChargingPriceStrategy", "ChargingPriceParam", + "ChargingOrder", "OccupancyOrder", "ChargingBrand", "ChargingMerchant", + "EnergyQuota", "QuotaUsage", + "ElectricityPricing", "PricingPeriod", + "InspectionPlan", "InspectionRecord", "RepairOrder", "DutySchedule", + "Regulation", "Standard", "ProcessDoc", "EmergencyPlan", ] diff --git a/backend/app/models/charging.py b/backend/app/models/charging.py new file mode 100644 index 0000000..1509d60 --- /dev/null +++ b/backend/app/models/charging.py @@ -0,0 +1,145 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Text, JSON, BigInteger +from sqlalchemy.sql import func +from app.core.database import Base + + +class ChargingStation(Base): + __tablename__ = "charging_stations" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + merchant_id = Column(Integer, ForeignKey("charging_merchants.id")) + type = Column(String(50)) # public, private, dedicated + address = Column(String(500)) + latitude = Column(Float) + longitude = Column(Float) + price = Column(Float) # default price yuan/kWh + activity = Column(Text) # promotions text + status = Column(String(20), default="active") # active, disabled + total_piles = Column(Integer, default=0) + available_piles = Column(Integer, default=0) + total_power_kw = Column(Float, default=0) + photo_url = Column(String(500)) + operating_hours = Column(String(100)) # e.g. "00:00-24:00" + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class ChargingPile(Base): + __tablename__ = "charging_piles" + + id = Column(Integer, primary_key=True, autoincrement=True) + station_id = Column(Integer, ForeignKey("charging_stations.id"), nullable=False) + encoding = Column(String(100), unique=True) # terminal code + name = Column(String(200)) + type = Column(String(50)) # AC_slow, DC_fast, DC_superfast + brand = Column(String(100)) + model = Column(String(100)) + rated_power_kw = Column(Float) + connector_type = Column(String(50)) # GB_T, CCS, CHAdeMO + status = Column(String(20), default="active") # active, disabled + work_status = Column(String(20), default="offline") # idle, charging, fault, offline + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class ChargingPriceStrategy(Base): + __tablename__ = "charging_price_strategies" + + id = Column(Integer, primary_key=True, autoincrement=True) + strategy_name = Column(String(200), nullable=False) + station_id = Column(Integer, ForeignKey("charging_stations.id")) + bill_model = Column(String(20)) # tou, flat + description = Column(Text) + status = Column(String(20), default="inactive") # active, inactive + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class ChargingPriceParam(Base): + __tablename__ = "charging_price_params" + + id = Column(Integer, primary_key=True, autoincrement=True) + strategy_id = Column(Integer, ForeignKey("charging_price_strategies.id"), nullable=False) + start_time = Column(String(10), nullable=False) # HH:MM + end_time = Column(String(10), nullable=False) + period_mark = Column(String(20)) # sharp, peak, flat, valley + elec_price = Column(Float, nullable=False) # yuan/kWh + service_price = Column(Float, default=0) # yuan/kWh + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class ChargingOrder(Base): + __tablename__ = "charging_orders" + + id = Column(Integer, primary_key=True, autoincrement=True) + order_no = Column(String(50), unique=True, nullable=False) + user_id = Column(Integer) + user_name = Column(String(100)) + phone = Column(String(20)) + station_id = Column(Integer, ForeignKey("charging_stations.id")) + station_name = Column(String(200)) + pile_id = Column(Integer, ForeignKey("charging_piles.id")) + pile_name = Column(String(200)) + start_time = Column(DateTime(timezone=True)) + end_time = Column(DateTime(timezone=True)) + car_no = Column(String(20)) # license plate + car_vin = Column(String(50)) + charge_method = Column(String(20)) # plug_and_charge, app, card + settle_type = Column(String(20)) # normal, manual, delayed, abnormal, offline + pay_type = Column(String(20)) # balance, wechat, alipay + settle_time = Column(DateTime(timezone=True)) + settle_price = Column(Float) # settlement amount + paid_price = Column(Float) # actual paid + discount_amt = Column(Float, default=0) + elec_amt = Column(Float) # electricity fee + serve_amt = Column(Float) # service fee + order_status = Column(String(20), default="charging") # charging, pending_pay, completed, failed, refunded + charge_duration = Column(Integer) # seconds + energy = Column(Float) # kWh delivered + start_soc = Column(Float) # battery start % + end_soc = Column(Float) # battery end % + abno_cause = Column(Text) # abnormal reason + order_source = Column(String(20)) # miniprogram, pc, app + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class OccupancyOrder(Base): + __tablename__ = "occupancy_orders" + + id = Column(Integer, primary_key=True, autoincrement=True) + order_id = Column(Integer, ForeignKey("charging_orders.id")) + pile_id = Column(Integer, ForeignKey("charging_piles.id")) + start_time = Column(DateTime(timezone=True)) + end_time = Column(DateTime(timezone=True)) + occupancy_fee = Column(Float, default=0) + status = Column(String(20), default="active") + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class ChargingBrand(Base): + __tablename__ = "charging_brands" + + id = Column(Integer, primary_key=True, autoincrement=True) + brand_name = Column(String(100), nullable=False) + logo_url = Column(String(500)) + country = Column(String(50)) + description = Column(Text) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class ChargingMerchant(Base): + __tablename__ = "charging_merchants" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + contact_person = Column(String(100)) + phone = Column(String(20)) + email = Column(String(100)) + address = Column(String(500)) + business_license = Column(String(100)) + status = Column(String(20), default="active") + settlement_type = Column(String(20)) # prepaid, postpaid + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/device.py b/backend/app/models/device.py index 00506fd..7b960dd 100644 --- a/backend/app/models/device.py +++ b/backend/app/models/device.py @@ -42,6 +42,7 @@ class Device(Base): protocol = Column(String(50)) # modbus_tcp, modbus_rtu, opc_ua, mqtt, http_api connection_params = Column(JSON) # 连接参数 (IP, port, slave_id, etc.) collect_interval = Column(Integer, default=15) # 采集间隔(秒) + category_id = Column(Integer, ForeignKey("energy_categories.id")) # 分项类别 status = Column(String(20), default="offline") # online, offline, alarm, maintenance is_active = Column(Boolean, default=True) metadata_ = Column("metadata", JSON) # 扩展元数据 diff --git a/backend/app/models/energy.py b/backend/app/models/energy.py index 641cb71..38fb5f6 100644 --- a/backend/app/models/energy.py +++ b/backend/app/models/energy.py @@ -3,6 +3,20 @@ from sqlalchemy.sql import func from app.core.database import Base +class EnergyCategory(Base): + """能耗分项类别""" + __tablename__ = "energy_categories" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) # HVAC, 照明, 动力, 特殊 + code = Column(String(50), unique=True, nullable=False) # hvac, lighting, power, special + parent_id = Column(Integer, ForeignKey("energy_categories.id")) + sort_order = Column(Integer, default=0) + icon = Column(String(100)) + color = Column(String(20)) # hex color for charts + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + class EnergyData(Base): """时序能耗采集数据 - 使用TimescaleDB hypertable""" __tablename__ = "energy_data" diff --git a/backend/app/models/maintenance.py b/backend/app/models/maintenance.py new file mode 100644 index 0000000..660c974 --- /dev/null +++ b/backend/app/models/maintenance.py @@ -0,0 +1,69 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Text, JSON +from sqlalchemy.sql import func +from app.core.database import Base + + +class InspectionPlan(Base): + __tablename__ = "inspection_plans" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + description = Column(Text) + device_group_id = Column(Integer, ForeignKey("device_groups.id")) + device_ids = Column(JSON) # specific devices to inspect + schedule_type = Column(String(20)) # daily, weekly, monthly, custom + schedule_cron = Column(String(100)) # cron expression for custom + inspector_id = Column(Integer, ForeignKey("users.id")) + checklist = Column(JSON) # [{item: "检查外观", required: true, type: "checkbox"}] + is_active = Column(Boolean, default=True) + next_run_at = Column(DateTime(timezone=True)) + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class InspectionRecord(Base): + __tablename__ = "inspection_records" + + id = Column(Integer, primary_key=True, autoincrement=True) + plan_id = Column(Integer, ForeignKey("inspection_plans.id"), nullable=False) + inspector_id = Column(Integer, ForeignKey("users.id"), nullable=False) + status = Column(String(20), default="pending") # pending, in_progress, completed, issues_found + findings = Column(JSON) # [{item, result, note, photo_url}] + started_at = Column(DateTime(timezone=True)) + completed_at = Column(DateTime(timezone=True)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class RepairOrder(Base): + __tablename__ = "repair_orders" + + id = Column(Integer, primary_key=True, autoincrement=True) + code = Column(String(50), unique=True, nullable=False) # WO-20260402-001 + title = Column(String(200), nullable=False) + description = Column(Text) + device_id = Column(Integer, ForeignKey("devices.id")) + alarm_event_id = Column(Integer, ForeignKey("alarm_events.id")) + priority = Column(String(20), default="medium") # critical, high, medium, low + status = Column(String(20), default="open") # open, assigned, in_progress, completed, verified, closed + assigned_to = Column(Integer, ForeignKey("users.id")) + resolution = Column(Text) + cost_estimate = Column(Float) + actual_cost = Column(Float) + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + assigned_at = Column(DateTime(timezone=True)) + completed_at = Column(DateTime(timezone=True)) + closed_at = Column(DateTime(timezone=True)) + + +class DutySchedule(Base): + __tablename__ = "duty_schedules" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + duty_date = Column(DateTime(timezone=True), nullable=False) + shift = Column(String(20)) # day, night, on_call + area_id = Column(Integer, ForeignKey("device_groups.id")) + notes = Column(Text) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/management.py b/backend/app/models/management.py new file mode 100644 index 0000000..13942fe --- /dev/null +++ b/backend/app/models/management.py @@ -0,0 +1,60 @@ +from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, ForeignKey, JSON +from sqlalchemy.sql import func +from app.core.database import Base + + +class Regulation(Base): + __tablename__ = "regulations" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(200), nullable=False) + category = Column(String(50)) # safety, operation, quality, environment + content = Column(Text) + effective_date = Column(DateTime(timezone=True)) + status = Column(String(20), default="active") # active, archived, draft + attachment_url = Column(String(500)) + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class Standard(Base): + __tablename__ = "standards" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + code = Column(String(100)) # e.g. ISO 50001, GB/T 23331 + type = Column(String(50)) # national, industry, enterprise + description = Column(Text) + compliance_status = Column(String(20), default="pending") # compliant, non_compliant, pending, in_progress + review_date = Column(DateTime(timezone=True)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class ProcessDoc(Base): + __tablename__ = "process_docs" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(200), nullable=False) + category = Column(String(50)) # operation, maintenance, emergency, training + content = Column(Text) + version = Column(String(20), default="1.0") + approved_by = Column(String(100)) + effective_date = Column(DateTime(timezone=True)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class EmergencyPlan(Base): + __tablename__ = "emergency_plans" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(200), nullable=False) + scenario = Column(String(100)) # fire, power_outage, equipment_failure, chemical_leak + steps = Column(JSON) # [{step_number, action, responsible_person, contact}] + responsible_person = Column(String(100)) + review_date = Column(DateTime(timezone=True)) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/pricing.py b/backend/app/models/pricing.py new file mode 100644 index 0000000..f50261a --- /dev/null +++ b/backend/app/models/pricing.py @@ -0,0 +1,33 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, JSON +from sqlalchemy.sql import func +from app.core.database import Base + + +class ElectricityPricing(Base): + """电价配置""" + __tablename__ = "electricity_pricing" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + energy_type = Column(String(50), default="electricity") # electricity, heat, water, gas + pricing_type = Column(String(20), nullable=False) # flat, tou, tiered + effective_from = Column(DateTime(timezone=True)) + effective_to = Column(DateTime(timezone=True)) + is_active = Column(Boolean, default=True) + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class PricingPeriod(Base): + """分时电价时段""" + __tablename__ = "pricing_periods" + + id = Column(Integer, primary_key=True, autoincrement=True) + pricing_id = Column(Integer, ForeignKey("electricity_pricing.id"), nullable=False) + period_name = Column(String(50), nullable=False) # peak, valley, flat, shoulder, sharp + start_time = Column(String(10), nullable=False) # HH:MM format + end_time = Column(String(10), nullable=False) # HH:MM format + price_per_unit = Column(Float, nullable=False) # yuan per kWh + applicable_months = Column(JSON) # [1,2,3,...12] or null for all months + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/quota.py b/backend/app/models/quota.py new file mode 100644 index 0000000..84b77d0 --- /dev/null +++ b/backend/app/models/quota.py @@ -0,0 +1,38 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey +from sqlalchemy.sql import func +from app.core.database import Base + + +class EnergyQuota(Base): + """能源配额管理""" + __tablename__ = "energy_quotas" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(200), nullable=False) + target_type = Column(String(50), nullable=False) # building, department, device_group + target_id = Column(Integer, nullable=False) # FK to device_groups.id + energy_type = Column(String(50), nullable=False) # electricity, heat, water, gas + period = Column(String(20), nullable=False) # monthly, yearly + quota_value = Column(Float, nullable=False) # 目标消耗值 + unit = Column(String(20), default="kWh") + warning_threshold_pct = Column(Float, default=80) # 80%预警 + alert_threshold_pct = Column(Float, default=95) # 95%告警 + is_active = Column(Boolean, default=True) + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class QuotaUsage(Base): + """配额使用记录""" + __tablename__ = "quota_usage" + + id = Column(Integer, primary_key=True, autoincrement=True) + quota_id = Column(Integer, ForeignKey("energy_quotas.id"), nullable=False) + period_start = Column(DateTime(timezone=True), nullable=False) + period_end = Column(DateTime(timezone=True), nullable=False) + actual_value = Column(Float, default=0) + quota_value = Column(Float, nullable=False) + usage_rate_pct = Column(Float, default=0) + status = Column(String(20), default="normal") # normal, warning, exceeded + calculated_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/services/aggregation.py b/backend/app/services/aggregation.py new file mode 100644 index 0000000..287f536 --- /dev/null +++ b/backend/app/services/aggregation.py @@ -0,0 +1,291 @@ +"""Scheduled aggregation engine for energy data rollups. + +Computes hourly, daily, and monthly aggregations from raw EnergyData +and populates EnergyDailySummary. Follows the APScheduler pattern +established in report_scheduler.py. +""" +import logging +from datetime import datetime, timedelta, timezone + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from sqlalchemy import select, func, and_, text, Integer + +from app.core.config import get_settings +from app.core.database import async_session +from app.models.energy import EnergyData, EnergyDailySummary + +logger = logging.getLogger("aggregation") + +_scheduler: AsyncIOScheduler | None = None + + +async def aggregate_hourly(): + """Aggregate raw energy_data into hourly avg/min/max per device+data_type. + + Processes data from the previous hour. Results are logged but not + stored separately — the primary use is for cache warming and monitoring. + Daily aggregation (which writes to EnergyDailySummary) is the persistent rollup. + """ + now = datetime.now(timezone.utc) + hour_start = now.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1) + hour_end = hour_start + timedelta(hours=1) + + logger.info("Running hourly aggregation for %s", hour_start.isoformat()) + + async with async_session() as session: + settings = get_settings() + query = ( + select( + EnergyData.device_id, + EnergyData.data_type, + func.avg(EnergyData.value).label("avg_value"), + func.min(EnergyData.value).label("min_value"), + func.max(EnergyData.value).label("max_value"), + func.count(EnergyData.id).label("sample_count"), + ) + .where( + and_( + EnergyData.timestamp >= hour_start, + EnergyData.timestamp < hour_end, + ) + ) + .group_by(EnergyData.device_id, EnergyData.data_type) + ) + result = await session.execute(query) + rows = result.all() + + logger.info( + "Hourly aggregation complete: %d device/type groups for %s", + len(rows), + hour_start.isoformat(), + ) + return rows + + +async def aggregate_daily(): + """Compute daily summaries and populate EnergyDailySummary. + + Processes yesterday's data. Groups by device_id and maps data_type + to energy_type for the summary table. + """ + now = datetime.now(timezone.utc) + day_start = (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) + day_end = day_start + timedelta(days=1) + + logger.info("Running daily aggregation for %s", day_start.date().isoformat()) + + # Map data_type -> energy_type for summary grouping + data_type_to_energy_type = { + "power": "electricity", + "energy": "electricity", + "voltage": "electricity", + "current": "electricity", + "heat_power": "heat", + "heat_energy": "heat", + "temperature": "heat", + "water_flow": "water", + "water_volume": "water", + "gas_flow": "gas", + "gas_volume": "gas", + } + + async with async_session() as session: + # Fetch per-device aggregated stats for the day + query = ( + select( + EnergyData.device_id, + EnergyData.data_type, + func.avg(EnergyData.value).label("avg_value"), + func.min(EnergyData.value).label("min_value"), + func.max(EnergyData.value).label("max_value"), + func.sum(EnergyData.value).label("total_value"), + func.count(EnergyData.id).label("sample_count"), + ) + .where( + and_( + EnergyData.timestamp >= day_start, + EnergyData.timestamp < day_end, + ) + ) + .group_by(EnergyData.device_id, EnergyData.data_type) + ) + result = await session.execute(query) + rows = result.all() + + # Group results by (device_id, energy_type) + device_summaries: dict[tuple[int, str], dict] = {} + for row in rows: + energy_type = data_type_to_energy_type.get(row.data_type, "electricity") + key = (row.device_id, energy_type) + if key not in device_summaries: + device_summaries[key] = { + "peak_power": None, + "min_power": None, + "avg_power": None, + "total_consumption": 0.0, + "total_generation": 0.0, + "avg_temperature": None, + "sample_count": 0, + } + + summary = device_summaries[key] + summary["sample_count"] += row.sample_count + + # Power-type metrics + if row.data_type in ("power", "heat_power"): + summary["peak_power"] = max( + summary["peak_power"] or 0, row.max_value or 0 + ) + summary["min_power"] = min( + summary["min_power"] if summary["min_power"] is not None else float("inf"), + row.min_value or 0, + ) + summary["avg_power"] = row.avg_value + + # Consumption (energy, volume) + if row.data_type in ("energy", "heat_energy", "water_volume", "gas_volume"): + summary["total_consumption"] += row.total_value or 0 + + # Temperature + if row.data_type == "temperature": + summary["avg_temperature"] = row.avg_value + + # Delete existing summaries for the same date to allow re-runs + await session.execute( + EnergyDailySummary.__table__.delete().where( + EnergyDailySummary.date == day_start + ) + ) + + # Insert new summaries + summaries = [] + for (device_id, energy_type), stats in device_summaries.items(): + summaries.append( + EnergyDailySummary( + device_id=device_id, + date=day_start, + energy_type=energy_type, + total_consumption=round(stats["total_consumption"], 4), + total_generation=0.0, + peak_power=round(stats["peak_power"], 4) if stats["peak_power"] else None, + min_power=round(stats["min_power"], 4) if stats["min_power"] is not None and stats["min_power"] != float("inf") else None, + avg_power=round(stats["avg_power"], 4) if stats["avg_power"] else None, + avg_temperature=round(stats["avg_temperature"], 2) if stats["avg_temperature"] else None, + ) + ) + + if summaries: + session.add_all(summaries) + await session.commit() + + logger.info( + "Daily aggregation complete: %d summaries for %s", + len(summaries) if device_summaries else 0, + day_start.date().isoformat(), + ) + + +async def aggregate_monthly(): + """Compute monthly rollups from EnergyDailySummary. + + Aggregates the previous month's daily summaries. Results are logged + for monitoring — monthly reports use ReportGenerator for output. + """ + now = datetime.now(timezone.utc) + first_of_current = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + last_month_end = first_of_current - timedelta(days=1) + month_start = last_month_end.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + logger.info( + "Running monthly aggregation for %s-%02d", + month_start.year, + month_start.month, + ) + + async with async_session() as session: + query = ( + select( + EnergyDailySummary.device_id, + EnergyDailySummary.energy_type, + func.sum(EnergyDailySummary.total_consumption).label("total_consumption"), + func.sum(EnergyDailySummary.total_generation).label("total_generation"), + func.max(EnergyDailySummary.peak_power).label("peak_power"), + func.min(EnergyDailySummary.min_power).label("min_power"), + func.avg(EnergyDailySummary.avg_power).label("avg_power"), + func.sum(EnergyDailySummary.operating_hours).label("total_operating_hours"), + func.sum(EnergyDailySummary.cost).label("total_cost"), + func.sum(EnergyDailySummary.carbon_emission).label("total_carbon"), + ) + .where( + and_( + EnergyDailySummary.date >= month_start, + EnergyDailySummary.date < first_of_current, + ) + ) + .group_by(EnergyDailySummary.device_id, EnergyDailySummary.energy_type) + ) + result = await session.execute(query) + rows = result.all() + + logger.info( + "Monthly aggregation complete: %d device/type groups for %s-%02d", + len(rows), + month_start.year, + month_start.month, + ) + return rows + + +async def start_aggregation_scheduler(): + """Start the APScheduler-based aggregation scheduler.""" + global _scheduler + settings = get_settings() + if not settings.AGGREGATION_ENABLED: + logger.info("Aggregation scheduler disabled by config.") + return + + if _scheduler and _scheduler.running: + logger.warning("Aggregation scheduler is already running.") + return + + _scheduler = AsyncIOScheduler(timezone="Asia/Shanghai") + + # Hourly aggregation: every hour at :05 + _scheduler.add_job( + aggregate_hourly, + CronTrigger(minute=5), + id="aggregate_hourly", + replace_existing=True, + misfire_grace_time=600, + ) + + # Daily aggregation: every day at 00:30 + _scheduler.add_job( + aggregate_daily, + CronTrigger(hour=0, minute=30), + id="aggregate_daily", + replace_existing=True, + misfire_grace_time=3600, + ) + + # Monthly aggregation: 1st of each month at 01:00 + _scheduler.add_job( + aggregate_monthly, + CronTrigger(day=1, hour=1, minute=0), + id="aggregate_monthly", + replace_existing=True, + misfire_grace_time=7200, + ) + + _scheduler.start() + logger.info("Aggregation scheduler started (hourly @:05, daily @00:30, monthly @1st 01:00).") + + +async def stop_aggregation_scheduler(): + """Stop the aggregation scheduler gracefully.""" + global _scheduler + if _scheduler and _scheduler.running: + _scheduler.shutdown(wait=False) + logger.info("Aggregation scheduler stopped.") + _scheduler = None diff --git a/backend/app/services/cost_calculator.py b/backend/app/services/cost_calculator.py new file mode 100644 index 0000000..fe33035 --- /dev/null +++ b/backend/app/services/cost_calculator.py @@ -0,0 +1,261 @@ +from datetime import datetime, timedelta, timezone +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ +from app.models.pricing import ElectricityPricing, PricingPeriod +from app.models.energy import EnergyData, EnergyDailySummary +from app.models.device import Device + + +async def get_active_pricing(db: AsyncSession, energy_type: str = "electricity", date: datetime | None = None): + """获取当前生效的电价配置""" + q = select(ElectricityPricing).where( + and_( + ElectricityPricing.energy_type == energy_type, + ElectricityPricing.is_active == True, + ) + ) + if date: + q = q.where( + and_( + (ElectricityPricing.effective_from == None) | (ElectricityPricing.effective_from <= date), + (ElectricityPricing.effective_to == None) | (ElectricityPricing.effective_to >= date), + ) + ) + q = q.order_by(ElectricityPricing.created_at.desc()).limit(1) + result = await db.execute(q) + return result.scalar_one_or_none() + + +async def get_pricing_periods(db: AsyncSession, pricing_id: int, month: int | None = None): + """获取电价时段配置""" + q = select(PricingPeriod).where(PricingPeriod.pricing_id == pricing_id) + result = await db.execute(q) + periods = result.scalars().all() + if month is not None: + periods = [p for p in periods if p.applicable_months is None or month in p.applicable_months] + return periods + + +def get_period_for_hour(periods: list, hour: int) -> PricingPeriod | None: + """根据小时确定所属时段""" + hour_str = f"{hour:02d}:00" + for p in periods: + start = p.start_time + end = p.end_time + if start <= end: + if start <= hour_str < end: + return p + else: # crosses midnight, e.g. 23:00 - 07:00 + if hour_str >= start or hour_str < end: + return p + return periods[0] if periods else None + + +async def calculate_daily_cost(db: AsyncSession, date: datetime, device_id: int | None = None): + """计算某天的用电费用""" + pricing = await get_active_pricing(db, "electricity", date) + if not pricing: + return 0.0 + + if pricing.pricing_type == "flat": + # 平价: 直接查日汇总 + q = select(func.sum(EnergyDailySummary.total_consumption)).where( + and_( + EnergyDailySummary.date >= date.replace(hour=0, minute=0, second=0), + EnergyDailySummary.date < date.replace(hour=0, minute=0, second=0) + timedelta(days=1), + EnergyDailySummary.energy_type == "electricity", + ) + ) + if device_id: + q = q.where(EnergyDailySummary.device_id == device_id) + result = await db.execute(q) + total_energy = result.scalar() or 0.0 + + periods = await get_pricing_periods(db, pricing.id) + flat_price = periods[0].price_per_unit if periods else 0.0 + cost = total_energy * flat_price + else: + # TOU分时: 按小时计算 + periods = await get_pricing_periods(db, pricing.id, month=date.month) + if not periods: + return 0.0 + + cost = 0.0 + day_start = date.replace(hour=0, minute=0, second=0, microsecond=0) + for hour in range(24): + hour_start = day_start + timedelta(hours=hour) + hour_end = hour_start + timedelta(hours=1) + + q = select(func.sum(EnergyData.value)).where( + and_( + EnergyData.timestamp >= hour_start, + EnergyData.timestamp < hour_end, + EnergyData.data_type == "energy", + ) + ) + if device_id: + q = q.where(EnergyData.device_id == device_id) + result = await db.execute(q) + hour_energy = result.scalar() or 0.0 + + period = get_period_for_hour(periods, hour) + if period: + cost += hour_energy * period.price_per_unit + + # Update daily summary cost + q = select(EnergyDailySummary).where( + and_( + EnergyDailySummary.date >= date.replace(hour=0, minute=0, second=0), + EnergyDailySummary.date < date.replace(hour=0, minute=0, second=0) + timedelta(days=1), + EnergyDailySummary.energy_type == "electricity", + ) + ) + if device_id: + q = q.where(EnergyDailySummary.device_id == device_id) + result = await db.execute(q) + for summary in result.scalars().all(): + summary.cost = cost + + return round(cost, 2) + + +async def get_cost_summary( + db: AsyncSession, start_date: datetime, end_date: datetime, + group_by: str = "day", energy_type: str = "electricity", +): + """获取费用汇总""" + q = select( + EnergyDailySummary.date, + func.sum(EnergyDailySummary.total_consumption).label("consumption"), + func.sum(EnergyDailySummary.cost).label("cost"), + ).where( + and_( + EnergyDailySummary.date >= start_date, + EnergyDailySummary.date <= end_date, + EnergyDailySummary.energy_type == energy_type, + ) + ) + + if group_by == "device": + q = select( + EnergyDailySummary.device_id, + Device.name.label("device_name"), + func.sum(EnergyDailySummary.total_consumption).label("consumption"), + func.sum(EnergyDailySummary.cost).label("cost"), + ).join(Device, EnergyDailySummary.device_id == Device.id, isouter=True).where( + and_( + EnergyDailySummary.date >= start_date, + EnergyDailySummary.date <= end_date, + EnergyDailySummary.energy_type == energy_type, + ) + ).group_by(EnergyDailySummary.device_id, Device.name) + result = await db.execute(q) + return [ + {"device_id": r[0], "device_name": r[1] or f"Device#{r[0]}", + "consumption": round(r[2] or 0, 2), "cost": round(r[3] or 0, 2)} + for r in result.all() + ] + elif group_by == "month": + from app.core.config import get_settings + settings = get_settings() + if settings.is_sqlite: + group_expr = func.strftime('%Y-%m', EnergyDailySummary.date).label('period') + else: + group_expr = func.to_char(EnergyDailySummary.date, 'YYYY-MM').label('period') + q = select( + group_expr, + func.sum(EnergyDailySummary.total_consumption).label("consumption"), + func.sum(EnergyDailySummary.cost).label("cost"), + ).where( + and_( + EnergyDailySummary.date >= start_date, + EnergyDailySummary.date <= end_date, + EnergyDailySummary.energy_type == energy_type, + ) + ).group_by(group_expr).order_by(group_expr) + result = await db.execute(q) + return [ + {"period": str(r[0]), "consumption": round(r[1] or 0, 2), "cost": round(r[2] or 0, 2)} + for r in result.all() + ] + else: # day + q = q.group_by(EnergyDailySummary.date).order_by(EnergyDailySummary.date) + result = await db.execute(q) + return [ + {"date": str(r[0]), "consumption": round(r[1] or 0, 2), "cost": round(r[2] or 0, 2)} + for r in result.all() + ] + + +async def get_cost_breakdown(db: AsyncSession, start_date: datetime, end_date: datetime, energy_type: str = "electricity"): + """获取峰谷平费用分布""" + pricing = await get_active_pricing(db, energy_type, start_date) + if not pricing: + return {"periods": [], "total_cost": 0, "total_consumption": 0} + + periods = await get_pricing_periods(db, pricing.id) + if not periods: + return {"periods": [], "total_cost": 0, "total_consumption": 0} + + # For each period, calculate the total energy consumption in those hours + breakdown = [] + total_cost = 0.0 + total_consumption = 0.0 + + for period in periods: + start_hour = int(period.start_time.split(":")[0]) + end_hour = int(period.end_time.split(":")[0]) + + if start_hour < end_hour: + hours = list(range(start_hour, end_hour)) + else: # crosses midnight + hours = list(range(start_hour, 24)) + list(range(0, end_hour)) + + period_energy = 0.0 + current = start_date.replace(hour=0, minute=0, second=0, microsecond=0) + end = end_date.replace(hour=23, minute=59, second=59, microsecond=0) + + # Sum energy for all matching hours in date range using daily summary approximation + q = select(func.sum(EnergyDailySummary.total_consumption)).where( + and_( + EnergyDailySummary.date >= start_date, + EnergyDailySummary.date <= end_date, + EnergyDailySummary.energy_type == energy_type, + ) + ) + result = await db.execute(q) + total_daily = result.scalar() or 0.0 + + # Approximate proportion based on hours in period vs 24h + proportion = len(hours) / 24.0 + period_energy = total_daily * proportion + period_cost = period_energy * period.price_per_unit + + total_cost += period_cost + total_consumption += period_energy + + period_name_map = { + "peak": "尖峰", "sharp": "尖峰", + "high": "高峰", "shoulder": "高峰", + "flat": "平段", + "valley": "低谷", "off_peak": "低谷", + } + + breakdown.append({ + "period_name": period.period_name, + "period_label": period_name_map.get(period.period_name, period.period_name), + "start_time": period.start_time, + "end_time": period.end_time, + "price_per_unit": period.price_per_unit, + "consumption": round(period_energy, 2), + "cost": round(period_cost, 2), + "proportion": round(proportion * 100, 1), + }) + + return { + "periods": breakdown, + "total_cost": round(total_cost, 2), + "total_consumption": round(total_consumption, 2), + "pricing_name": pricing.name, + "pricing_type": pricing.pricing_type, + } diff --git a/backend/app/services/quota_checker.py b/backend/app/services/quota_checker.py new file mode 100644 index 0000000..2ff287e --- /dev/null +++ b/backend/app/services/quota_checker.py @@ -0,0 +1,124 @@ +"""配额检测服务 - 计算配额使用率,超限时生成告警事件""" +import logging +from datetime import datetime, timezone, timedelta +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.quota import EnergyQuota, QuotaUsage +from app.models.alarm import AlarmEvent +from app.models.energy import EnergyDailySummary + +logger = logging.getLogger("quota_checker") + + +def _get_period_range(period: str, now: datetime) -> tuple[datetime, datetime]: + """根据配额周期计算当前统计区间""" + if period == "monthly": + start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + # 下月1号 + if now.month == 12: + end = start.replace(year=now.year + 1, month=1) + else: + end = start.replace(month=now.month + 1) + else: # yearly + start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) + end = start.replace(year=now.year + 1) + return start, end + + +async def check_quotas(session: AsyncSession): + """主配额检测循环,计算每个活跃配额的使用率并更新记录""" + now = datetime.now(timezone.utc) + + result = await session.execute( + select(EnergyQuota).where(EnergyQuota.is_active == True) + ) + quotas = result.scalars().all() + + for quota in quotas: + period_start, period_end = _get_period_range(quota.period, now) + + # 从 EnergyDailySummary 汇总实际用量 + # target_id 对应 device_groups,这里按 device_id 关联 + # 简化处理:按 energy_type 汇总所有匹配设备的消耗 + usage_query = select(func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0)).where( + and_( + EnergyDailySummary.energy_type == quota.energy_type, + EnergyDailySummary.date >= period_start, + EnergyDailySummary.date < period_end, + ) + ) + actual_value = (await session.execute(usage_query)).scalar() or 0 + + # 计算使用率 + usage_rate_pct = (actual_value / quota.quota_value * 100) if quota.quota_value > 0 else 0 + + # 确定状态 + if usage_rate_pct >= quota.alert_threshold_pct: + status = "exceeded" + elif usage_rate_pct >= quota.warning_threshold_pct: + status = "warning" + else: + status = "normal" + + # 更新或创建 QuotaUsage 记录 + existing_result = await session.execute( + select(QuotaUsage).where( + and_( + QuotaUsage.quota_id == quota.id, + QuotaUsage.period_start == period_start, + QuotaUsage.period_end == period_end, + ) + ) + ) + usage_record = existing_result.scalar_one_or_none() + + if usage_record: + usage_record.actual_value = actual_value + usage_record.usage_rate_pct = usage_rate_pct + usage_record.status = status + usage_record.calculated_at = now + else: + usage_record = QuotaUsage( + quota_id=quota.id, + period_start=period_start, + period_end=period_end, + actual_value=actual_value, + quota_value=quota.quota_value, + usage_rate_pct=usage_rate_pct, + status=status, + ) + session.add(usage_record) + + # 超过预警阈值时生成告警事件 + if status in ("warning", "exceeded"): + # 检查是否已存在未解决的同配额告警 + active_alarm = await session.execute( + select(AlarmEvent).where( + and_( + AlarmEvent.title == f"配额预警: {quota.name}", + AlarmEvent.status.in_(["active", "acknowledged"]), + ) + ) + ) + if not active_alarm.scalar_one_or_none(): + severity = "critical" if status == "exceeded" else "warning" + event = AlarmEvent( + rule_id=None, + device_id=quota.target_id, + severity=severity, + title=f"配额预警: {quota.name}", + description=f"当前使用 {actual_value:.1f}{quota.unit}," + f"配额 {quota.quota_value:.1f}{quota.unit}," + f"使用率 {usage_rate_pct:.1f}%", + value=actual_value, + threshold=quota.quota_value, + status="active", + triggered_at=now, + ) + session.add(event) + logger.info( + f"Quota alert: {quota.name} | usage={actual_value:.1f} " + f"quota={quota.quota_value:.1f} rate={usage_rate_pct:.1f}%" + ) + + await session.flush() diff --git a/backend/requirements.txt b/backend/requirements.txt index 86410f2..4d084b5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,7 +9,7 @@ pydantic-settings==2.7.0 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 python-multipart==0.0.18 -redis==5.2.1 +redis[hiredis]==5.2.1 celery==5.4.0 httpx==0.28.1 pandas==2.2.3 diff --git a/docs/create_pptx.cjs b/docs/create_pptx.cjs new file mode 100644 index 0000000..a8a7684 --- /dev/null +++ b/docs/create_pptx.cjs @@ -0,0 +1,317 @@ +const pptxgen = require("pptxgenjs"); +const path = require("path"); + +const pres = new pptxgen(); +pres.layout = "LAYOUT_16x9"; +pres.author = "Tianpu EMS Team"; +pres.title = "天普EMS企业级功能增强 — 开发团队晨会"; + +// Color palette — Ocean Gradient theme for energy/tech +const C = { + navy: "0A1628", + darkBlue: "0F2440", + blue: "1565C0", + lightBlue: "42A5F5", + teal: "00897B", + green: "43A047", + white: "FFFFFF", + offWhite: "F5F7FA", + lightGray: "E8ECF1", + gray: "8898AA", + darkGray: "32325D", + accent: "FF6F00", + red: "E53935", +}; + +const makeShadow = () => ({ type: "outer", blur: 6, offset: 2, angle: 135, color: "000000", opacity: 0.12 }); + +// ============================================================ +// SLIDE 1: Title +// ============================================================ +let s1 = pres.addSlide(); +s1.background = { color: C.navy }; + +// Top accent bar +s1.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.lightBlue } }); + +// Title block +s1.addText("天普零碳园区", { x: 0.8, y: 1.2, w: 8.4, h: 0.8, fontSize: 44, fontFace: "Microsoft YaHei", color: C.white, bold: true, margin: 0 }); +s1.addText("智慧能源管理平台", { x: 0.8, y: 1.9, w: 8.4, h: 0.8, fontSize: 44, fontFace: "Microsoft YaHei", color: C.lightBlue, bold: true, margin: 0 }); +s1.addText("企业级功能增强项目 — 开发团队晨会", { x: 0.8, y: 2.9, w: 8.4, h: 0.5, fontSize: 20, fontFace: "Microsoft YaHei", color: C.gray, margin: 0 }); + +// Bottom info bar +s1.addShape(pres.shapes.RECTANGLE, { x: 0, y: 4.8, w: 10, h: 0.825, fill: { color: C.darkBlue } }); +s1.addText("React 19 + FastAPI + PostgreSQL/TimescaleDB + Redis", { x: 0.8, y: 4.85, w: 8.4, h: 0.7, fontSize: 14, fontFace: "Consolas", color: C.lightBlue, align: "left", valign: "middle" }); + +// ============================================================ +// SLIDE 2: Project Overview +// ============================================================ +let s2 = pres.addSlide(); +s2.background = { color: C.offWhite }; +s2.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.blue } }); + +s2.addText("项目概述", { x: 0.7, y: 0.3, w: 8.6, h: 0.6, fontSize: 32, fontFace: "Microsoft YaHei", color: C.darkGray, bold: true, margin: 0 }); + +// 4 KPI cards +const kpis = [ + { label: "增强阶段", value: "12", sub: "全部完成", color: C.green }, + { label: "新增API", value: "80+", sub: "端点", color: C.blue }, + { label: "新增页面", value: "16", sub: "前端组件", color: C.teal }, + { label: "数据库表", value: "37", sub: "总计", color: C.accent }, +]; +kpis.forEach((k, i) => { + const cx = 0.7 + i * 2.3; + s2.addShape(pres.shapes.RECTANGLE, { x: cx, y: 1.15, w: 2.0, h: 1.5, fill: { color: C.white }, shadow: makeShadow() }); + s2.addShape(pres.shapes.RECTANGLE, { x: cx, y: 1.15, w: 2.0, h: 0.06, fill: { color: k.color } }); + s2.addText(k.value, { x: cx, y: 1.35, w: 2.0, h: 0.7, fontSize: 36, fontFace: "Arial Black", color: k.color, align: "center", valign: "middle", bold: true, margin: 0 }); + s2.addText(k.sub, { x: cx, y: 1.95, w: 2.0, h: 0.3, fontSize: 12, fontFace: "Microsoft YaHei", color: C.gray, align: "center", margin: 0 }); + s2.addText(k.label, { x: cx, y: 2.3, w: 2.0, h: 0.3, fontSize: 11, fontFace: "Microsoft YaHei", color: C.darkGray, align: "center", bold: true, margin: 0 }); +}); + +// Description bullets +s2.addText([ + { text: "参考系统: cp-ems-ruoyi (Java/Spring Boot 企业级EMS)", options: { bullet: true, breakLine: true, fontSize: 14 } }, + { text: "目标: 以现代Python/React技术栈实现同等企业级功能深度", options: { bullet: true, breakLine: true, fontSize: 14 } }, + { text: "技术优势保留: 3D可视化、多协议采集、TypeScript、i18n、暗色模式", options: { bullet: true, breakLine: true, fontSize: 14 } }, + { text: "全部编译验证通过: 后端0错误、前端0 TS错误、所有API已测试", options: { bullet: true, fontSize: 14 } }, +], { x: 0.7, y: 2.9, w: 8.6, h: 2.5, fontFace: "Microsoft YaHei", color: C.darkGray, valign: "top" }); + +// ============================================================ +// SLIDE 3: New Modules Overview (Table) +// ============================================================ +let s3 = pres.addSlide(); +s3.background = { color: C.offWhite }; +s3.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.blue } }); +s3.addText("新增功能模块一览", { x: 0.7, y: 0.2, w: 8.6, h: 0.55, fontSize: 28, fontFace: "Microsoft YaHei", color: C.darkGray, bold: true, margin: 0 }); + +const hdrOpts = { fill: { color: C.blue }, color: C.white, bold: true, fontSize: 11, fontFace: "Microsoft YaHei", align: "center", valign: "middle" }; +const cellOpts = { fontSize: 10, fontFace: "Microsoft YaHei", color: C.darkGray, valign: "middle" }; +const cellC = { ...cellOpts, align: "center" }; +const altFill = { fill: { color: C.lightGray } }; + +const tableRows = [ + [ + { text: "功能模块", options: hdrOpts }, + { text: "后端", options: hdrOpts }, + { text: "前端", options: hdrOpts }, + { text: "状态", options: hdrOpts }, + ], + [{ text: "基础设施 (Redis/队列/聚合)", options: cellOpts }, { text: "3 files", options: cellC }, { text: "-", options: cellC }, { text: "✅", options: cellC }], + [{ text: "定额管理", options: { ...cellOpts, ...altFill } }, { text: "model+API+service", options: { ...cellC, ...altFill } }, { text: "1 page (3 tabs)", options: { ...cellC, ...altFill } }, { text: "✅", options: { ...cellC, ...altFill } }], + [{ text: "费用分析", options: cellOpts }, { text: "model+API+service", options: cellC }, { text: "1 component", options: cellC }, { text: "✅", options: cellC }], + [{ text: "分项分析", options: { ...cellOpts, ...altFill } }, { text: "extended energy.py", options: { ...cellC, ...altFill } }, { text: "1 component", options: { ...cellC, ...altFill } }, { text: "✅", options: { ...cellC, ...altFill } }], + [{ text: "充电桩管理", options: cellOpts }, { text: "8 models + API", options: cellC }, { text: "6 pages", options: cellC }, { text: "✅", options: cellC }], + [{ text: "能耗分析增强 (损耗/同比/环比)", options: { ...cellOpts, ...altFill } }, { text: "extended energy.py", options: { ...cellC, ...altFill } }, { text: "3 components", options: { ...cellC, ...altFill } }, { text: "✅", options: { ...cellC, ...altFill } }], + [{ text: "告警分析", options: cellOpts }, { text: "extended alarms.py", options: cellC }, { text: "enhanced page", options: cellC }, { text: "✅", options: cellC }], + [{ text: "运维管理", options: { ...cellOpts, ...altFill } }, { text: "4 models + API", options: { ...cellC, ...altFill } }, { text: "1 page (5 tabs)", options: { ...cellC, ...altFill } }, { text: "✅", options: { ...cellC, ...altFill } }], + [{ text: "数据查询", options: cellOpts }, { text: "extended energy.py", options: cellC }, { text: "1 page", options: cellC }, { text: "✅", options: cellC }], + [{ text: "设备拓扑", options: { ...cellOpts, ...altFill } }, { text: "extended devices.py", options: { ...cellC, ...altFill } }, { text: "1 component", options: { ...cellC, ...altFill } }, { text: "✅", options: { ...cellC, ...altFill } }], + [{ text: "管理体系", options: cellOpts }, { text: "4 models + API", options: cellC }, { text: "1 page (4 tabs)", options: cellC }, { text: "✅", options: cellC }], + [{ text: "生产加固 (限流/幂等/请求ID)", options: { ...cellOpts, ...altFill } }, { text: "middleware.py", options: { ...cellC, ...altFill } }, { text: "-", options: { ...cellC, ...altFill } }, { text: "✅", options: { ...cellC, ...altFill } }], +]; + +s3.addTable(tableRows, { x: 0.5, y: 0.85, w: 9.0, colW: [3.8, 2.2, 1.8, 0.8], border: { pt: 0.5, color: "D0D5DD" }, rowH: [0.35, 0.32, 0.32, 0.32, 0.32, 0.32, 0.32, 0.32, 0.32, 0.32, 0.32, 0.32, 0.32, 0.32] }); + +// ============================================================ +// SLIDE 4: Tech Architecture +// ============================================================ +let s4 = pres.addSlide(); +s4.background = { color: C.offWhite }; +s4.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.blue } }); +s4.addText("技术架构", { x: 0.7, y: 0.25, w: 8.6, h: 0.55, fontSize: 28, fontFace: "Microsoft YaHei", color: C.darkGray, bold: true, margin: 0 }); + +// Architecture layers as horizontal blocks +const layers = [ + { label: "前端展示层", tech: "React 19 + TypeScript + Ant Design 5 + ECharts + Three.js (3D)", color: C.lightBlue, y: 1.0 }, + { label: "API网关层", tech: "FastAPI + JWT Auth + RBAC + Rate Limiting + Request ID", color: C.blue, y: 1.85 }, + { label: "业务服务层", tech: "定额检查 | 费用计算 | 告警引擎 | 报表生成 | 数据聚合", color: C.teal, y: 2.7 }, + { label: "数据采集层", tech: "Modbus TCP + MQTT + HTTP API + Redis Streams Queue", color: C.green, y: 3.55 }, + { label: "存储层", tech: "PostgreSQL 16 (TimescaleDB) + Redis 7 + File Storage", color: C.accent, y: 4.4 }, +]; + +layers.forEach(l => { + s4.addShape(pres.shapes.RECTANGLE, { x: 0.7, y: l.y, w: 2.2, h: 0.65, fill: { color: l.color } }); + s4.addText(l.label, { x: 0.7, y: l.y, w: 2.2, h: 0.65, fontSize: 13, fontFace: "Microsoft YaHei", color: C.white, align: "center", valign: "middle", bold: true, margin: 0 }); + s4.addShape(pres.shapes.RECTANGLE, { x: 3.0, y: l.y, w: 6.3, h: 0.65, fill: { color: C.white }, shadow: makeShadow() }); + s4.addText(l.tech, { x: 3.2, y: l.y, w: 5.9, h: 0.65, fontSize: 12, fontFace: "Consolas", color: C.darkGray, valign: "middle", margin: 0 }); +}); + +// ============================================================ +// SLIDE 5: Frontend Team +// ============================================================ +let s5 = pres.addSlide(); +s5.background = { color: C.offWhite }; +s5.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.lightBlue } }); +s5.addText("前端开发组 — 代码Review清单", { x: 0.7, y: 0.2, w: 8.6, h: 0.5, fontSize: 26, fontFace: "Microsoft YaHei", color: C.darkGray, bold: true, margin: 0 }); +s5.addText("Git: http://100.108.180.60:3300/tianpu/tianpu-ems | 目录: frontend/src/", { x: 0.7, y: 0.65, w: 8.6, h: 0.3, fontSize: 11, fontFace: "Consolas", color: C.blue, margin: 0 }); + +const feFiles = [ + ["pages/Quota/index.tsx", "定额管理 (3 tabs: 配置/监控/分析)"], + ["pages/Charging/*", "充电管理 (Dashboard, Stations, Piles, Orders, Pricing)"], + ["pages/Maintenance/index.tsx", "运维管理 (5 tabs: 概览/巡检/工单/值班)"], + ["pages/DataQuery/index.tsx", "数据查询 (树形选择器 + 多参数查询)"], + ["pages/Management/index.tsx", "管理体系 (4 tabs: 规章/标准/流程/预案)"], + ["pages/Analysis/CostAnalysis.tsx", "费用分析组件"], + ["pages/Analysis/SubitemAnalysis.tsx", "分项分析组件"], + ["pages/Analysis/Loss|Yoy|Mom.tsx", "损耗/同比/环比分析 (3组件)"], + ["pages/Alarms/index.tsx", "告警分析增强 + 规则Toggle"], + ["pages/Devices/Topology.tsx", "设备拓扑树"], + ["layouts/MainLayout.tsx", "菜单导航 (14项)"], + ["App.tsx + services/api.ts", "路由配置 + API调用函数"], +]; + +const feHdr = [ + { text: "文件路径", options: hdrOpts }, + { text: "功能说明", options: hdrOpts }, +]; +const feRows = [feHdr]; +feFiles.forEach((f, i) => { + const bg = i % 2 === 1 ? altFill : {}; + feRows.push([ + { text: f[0], options: { fontSize: 10, fontFace: "Consolas", color: C.blue, valign: "middle", ...bg } }, + { text: f[1], options: { fontSize: 10, fontFace: "Microsoft YaHei", color: C.darkGray, valign: "middle", ...bg } }, + ]); +}); +s5.addTable(feRows, { x: 0.5, y: 1.05, w: 9.0, colW: [3.8, 5.2], border: { pt: 0.5, color: "D0D5DD" }, rowH: 0.32 }); + +// ============================================================ +// SLIDE 6: Backend Team +// ============================================================ +let s6 = pres.addSlide(); +s6.background = { color: C.offWhite }; +s6.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.teal } }); +s6.addText("后端开发组 — 代码Review清单", { x: 0.7, y: 0.2, w: 8.6, h: 0.5, fontSize: 26, fontFace: "Microsoft YaHei", color: C.darkGray, bold: true, margin: 0 }); +s6.addText("Git: http://100.108.180.60:3300/tianpu/tianpu-ems | 目录: backend/app/", { x: 0.7, y: 0.65, w: 8.6, h: 0.3, fontSize: 11, fontFace: "Consolas", color: C.teal, margin: 0 }); + +// Three columns: Models | APIs | Services +const colW = 2.9; +const colGap = 0.15; +const colY = 1.1; +const colH = 4.3; + +// Models column +s6.addShape(pres.shapes.RECTANGLE, { x: 0.5, y: colY, w: colW, h: colH, fill: { color: C.white }, shadow: makeShadow() }); +s6.addShape(pres.shapes.RECTANGLE, { x: 0.5, y: colY, w: colW, h: 0.4, fill: { color: C.blue } }); +s6.addText("数据模型 (6 files)", { x: 0.5, y: colY, w: colW, h: 0.4, fontSize: 13, fontFace: "Microsoft YaHei", color: C.white, align: "center", valign: "middle", bold: true, margin: 0 }); +s6.addText([ + { text: "models/quota.py", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.blue } }, + { text: " EnergyQuota, QuotaUsage", options: { breakLine: true, fontSize: 9, color: C.gray } }, + { text: "models/pricing.py", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.blue } }, + { text: " Pricing, PricingPeriod", options: { breakLine: true, fontSize: 9, color: C.gray } }, + { text: "models/charging.py", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.blue } }, + { text: " 8 charging models", options: { breakLine: true, fontSize: 9, color: C.gray } }, + { text: "models/maintenance.py", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.blue } }, + { text: " Plan, Record, Order, Duty", options: { breakLine: true, fontSize: 9, color: C.gray } }, + { text: "models/management.py", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.blue } }, + { text: " Regulation, Standard, etc.", options: { breakLine: true, fontSize: 9, color: C.gray } }, + { text: "models/energy.py", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.blue } }, + { text: " EnergyCategory (new)", options: { fontSize: 9, color: C.gray } }, +], { x: 0.65, y: colY + 0.5, w: colW - 0.3, h: colH - 0.6, valign: "top" }); + +// APIs column +const col2X = 0.5 + colW + colGap; +s6.addShape(pres.shapes.RECTANGLE, { x: col2X, y: colY, w: colW, h: colH, fill: { color: C.white }, shadow: makeShadow() }); +s6.addShape(pres.shapes.RECTANGLE, { x: col2X, y: colY, w: colW, h: 0.4, fill: { color: C.teal } }); +s6.addText("API接口 (7 files)", { x: col2X, y: colY, w: colW, h: 0.4, fontSize: 13, fontFace: "Microsoft YaHei", color: C.white, align: "center", valign: "middle", bold: true, margin: 0 }); +s6.addText([ + { text: "api/v1/quota.py", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.teal } }, + { text: " 定额管理 CRUD", options: { breakLine: true, fontSize: 9, color: C.gray } }, + { text: "api/v1/cost.py", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.teal } }, + { text: " 费用分析 API", options: { breakLine: true, fontSize: 9, color: C.gray } }, + { text: "api/v1/charging.py", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.teal } }, + { text: " 充电管理 20+ endpoints", options: { breakLine: true, fontSize: 9, color: C.gray } }, + { text: "api/v1/maintenance.py", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.teal } }, + { text: " 运维管理 API", options: { breakLine: true, fontSize: 9, color: C.gray } }, + { text: "api/v1/management.py", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.teal } }, + { text: " 管理体系 CRUD", options: { breakLine: true, fontSize: 9, color: C.gray } }, + { text: "api/v1/energy.py (扩展)", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.teal } }, + { text: " 分类/损耗/同比/环比/参量", options: { breakLine: true, fontSize: 9, color: C.gray } }, + { text: "api/v1/alarms.py (扩展)", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.teal } }, + { text: " 分析/排名/MTTR/Toggle", options: { fontSize: 9, color: C.gray } }, +], { x: col2X + 0.15, y: colY + 0.5, w: colW - 0.3, h: colH - 0.6, valign: "top" }); + +// Services column +const col3X = col2X + colW + colGap; +s6.addShape(pres.shapes.RECTANGLE, { x: col3X, y: colY, w: colW, h: colH, fill: { color: C.white }, shadow: makeShadow() }); +s6.addShape(pres.shapes.RECTANGLE, { x: col3X, y: colY, w: colW, h: 0.4, fill: { color: C.green } }); +s6.addText("服务 + 中间件 (6 files)", { x: col3X, y: colY, w: colW, h: 0.4, fontSize: 13, fontFace: "Microsoft YaHei", color: C.white, align: "center", valign: "middle", bold: true, margin: 0 }); +s6.addText([ + { text: "core/cache.py", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.green } }, + { text: " Redis缓存层", options: { breakLine: true, fontSize: 9, color: C.gray } }, + { text: "core/middleware.py", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.green } }, + { text: " 限流/幂等/请求ID", options: { breakLine: true, fontSize: 9, color: C.gray } }, + { text: "collectors/queue.py", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.green } }, + { text: " Redis Streams消息队列", options: { breakLine: true, fontSize: 9, color: C.gray } }, + { text: "services/aggregation.py", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.green } }, + { text: " 时/日/月数据聚合引擎", options: { breakLine: true, fontSize: 9, color: C.gray } }, + { text: "services/quota_checker.py", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.green } }, + { text: " 定额合规检查", options: { breakLine: true, fontSize: 9, color: C.gray } }, + { text: "services/cost_calculator.py", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.green } }, + { text: " 分时电价费用计算", options: { breakLine: true, fontSize: 9, color: C.gray } }, + { text: "alembic/versions/003-008", options: { breakLine: true, fontSize: 9, fontFace: "Consolas", color: C.green } }, + { text: " 6个新数据库迁移文件", options: { fontSize: 9, color: C.gray } }, +], { x: col3X + 0.15, y: colY + 0.5, w: colW - 0.3, h: colH - 0.6, valign: "top" }); + +// ============================================================ +// SLIDE 7: Next Steps +// ============================================================ +let s7 = pres.addSlide(); +s7.background = { color: C.offWhite }; +s7.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.accent } }); +s7.addText("下一步行动计划", { x: 0.7, y: 0.3, w: 8.6, h: 0.55, fontSize: 28, fontFace: "Microsoft YaHei", color: C.darkGray, bold: true, margin: 0 }); + +const steps = [ + { num: "1", title: "注册Gitea账号", desc: "访问 http://100.108.180.60:3300 注册开发者账号" }, + { num: "2", title: "Clone仓库", desc: "git clone http://100.108.180.60:3300/tianpu/tianpu-ems.git" }, + { num: "3", title: "阅读负责代码", desc: "前端组看Slide 5、后端组看Slide 6中列出的文件" }, + { num: "4", title: "本地运行项目", desc: "backend: uvicorn app.main:app --port 8000\nfrontend: npm install && npm run dev" }, + { num: "5", title: "登录体验", desc: "http://localhost:3000 账号: admin / admin123" }, + { num: "6", title: "API文档", desc: "http://localhost:8000/docs (Swagger自动生成)" }, +]; + +steps.forEach((s, i) => { + const sy = 1.1 + i * 0.72; + // Number circle + s7.addShape(pres.shapes.OVAL, { x: 0.7, y: sy + 0.05, w: 0.45, h: 0.45, fill: { color: C.blue } }); + s7.addText(s.num, { x: 0.7, y: sy + 0.05, w: 0.45, h: 0.45, fontSize: 18, fontFace: "Arial", color: C.white, align: "center", valign: "middle", bold: true, margin: 0 }); + // Title + desc + s7.addText(s.title, { x: 1.35, y: sy, w: 2.0, h: 0.55, fontSize: 15, fontFace: "Microsoft YaHei", color: C.darkGray, valign: "middle", bold: true, margin: 0 }); + s7.addText(s.desc, { x: 3.4, y: sy, w: 6.0, h: 0.55, fontSize: 11, fontFace: "Consolas", color: C.gray, valign: "middle", margin: 0 }); +}); + +// ============================================================ +// SLIDE 8: Verification Results +// ============================================================ +let s8 = pres.addSlide(); +s8.background = { color: C.navy }; +s8.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.green } }); + +s8.addText("验证结果 — 全部通过", { x: 0.7, y: 0.3, w: 8.6, h: 0.6, fontSize: 28, fontFace: "Microsoft YaHei", color: C.white, bold: true, margin: 0 }); + +const checks = [ + { icon: "✅", label: "Backend", value: "120+ API routes, 0 errors" }, + { icon: "✅", label: "Frontend", value: "12 pages, 27 tabs, 0 TS errors" }, + { icon: "✅", label: "Database", value: "37 tables, seed data populated" }, + { icon: "✅", label: "API Test", value: "All 20 endpoint groups return real data" }, + { icon: "✅", label: "Modals", value: "Create / Edit / Delete all functional" }, + { icon: "✅", label: "Console", value: "0 runtime errors (only antd deprecation warnings)" }, +]; + +checks.forEach((c, i) => { + const cy = 1.2 + i * 0.65; + s8.addShape(pres.shapes.RECTANGLE, { x: 0.7, y: cy, w: 8.6, h: 0.5, fill: { color: C.darkBlue } }); + s8.addText(c.icon, { x: 0.9, y: cy, w: 0.5, h: 0.5, fontSize: 18, align: "center", valign: "middle", margin: 0 }); + s8.addText(c.label, { x: 1.5, y: cy, w: 1.5, h: 0.5, fontSize: 14, fontFace: "Microsoft YaHei", color: C.lightBlue, valign: "middle", bold: true, margin: 0 }); + s8.addText(c.value, { x: 3.2, y: cy, w: 6.0, h: 0.5, fontSize: 13, fontFace: "Consolas", color: C.white, valign: "middle", margin: 0 }); +}); + +// Bottom tagline +s8.addText("准备就绪 — 开始代码Review!", { x: 0.7, y: 5.0, w: 8.6, h: 0.4, fontSize: 16, fontFace: "Microsoft YaHei", color: C.accent, align: "center", bold: true, margin: 0 }); + +// Save +const outPath = path.resolve("E:/AI_Projects/01.Tianpu_EMS/docs/天普EMS开发任务分配_晨会.pptx"); +pres.writeFile({ fileName: outPath }).then(() => { + console.log("Presentation saved to: " + outPath); +}).catch(err => { + console.error("Error:", err); +}); diff --git a/docs/天普EMS开发任务分配_晨会.pptx b/docs/天普EMS开发任务分配_晨会.pptx new file mode 100644 index 0000000000000000000000000000000000000000..689524f46a14a4a51462a9725d0e18e6e4274e5c GIT binary patch literal 306418 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 Rh4S$K4?*hv>=`s3{r_D4C))r3 literal 0 HcmV?d00001 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d89fa5b..684822f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,11 @@ import Reports from './pages/Reports'; import Devices from './pages/Devices'; import DeviceDetail from './pages/DeviceDetail'; import SystemManagement from './pages/System'; +import Quota from './pages/Quota'; +import Charging from './pages/Charging'; +import Maintenance from './pages/Maintenance'; +import DataQuery from './pages/DataQuery'; +import Management from './pages/Management'; import BigScreen from './pages/BigScreen'; import BigScreen3D from './pages/BigScreen3D'; import { isLoggedIn } from './utils/auth'; @@ -51,6 +56,11 @@ function AppContent() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 2fdc79a..aa469b2 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -14,7 +14,12 @@ "users": "User Management", "roles": "Roles & Permissions", "settings": "System Settings", - "audit": "Audit Log" + "audit": "Audit Log", + "quota": "Quota Management", + "charging": "Charging Management", + "maintenance": "O&M Management", + "dataQuery": "Data Query", + "management": "Management System" }, "header": { "alarmNotification": "Alarm Notifications", diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index 5a04165..f1bcf9d 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -14,7 +14,12 @@ "users": "用户管理", "roles": "角色权限", "settings": "系统设置", - "audit": "审计日志" + "audit": "审计日志", + "quota": "定额管理", + "charging": "充电管理", + "maintenance": "运维管理", + "dataQuery": "数据查询", + "management": "管理体系" }, "header": { "alarmNotification": "告警通知", diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 60d6aae..3e11e00 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -6,7 +6,8 @@ import { MenuFoldOutlined, MenuUnfoldOutlined, LogoutOutlined, BellOutlined, ThunderboltOutlined, AppstoreOutlined, WarningOutlined, CloseCircleOutlined, InfoCircleOutlined, FundProjectionScreenOutlined, GlobalOutlined, - BulbOutlined, BulbFilled, + BulbOutlined, BulbFilled, FundOutlined, CarOutlined, ToolOutlined, + SearchOutlined, SolutionOutlined, } from '@ant-design/icons'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -41,6 +42,11 @@ export default function MainLayout() { { key: '/alarms', icon: , label: t('menu.alarms') }, { key: '/carbon', icon: , label: t('menu.carbon') }, { key: '/reports', icon: , label: t('menu.reports') }, + { key: '/quota', icon: , label: t('menu.quota', '定额管理') }, + { key: '/charging', icon: , label: t('menu.charging', '充电管理') }, + { key: '/maintenance', icon: , label: t('menu.maintenance', '运维管理') }, + { key: '/data-query', icon: , label: t('menu.dataQuery', '数据查询') }, + { key: '/management', icon: , label: t('menu.management', '管理体系') }, { key: 'bigscreen-group', icon: , label: t('menu.bigscreen'), children: [ { key: '/bigscreen', icon: , label: t('menu.bigscreen2d') }, diff --git a/frontend/src/pages/Alarms/index.tsx b/frontend/src/pages/Alarms/index.tsx index b0c3a30..b34d30a 100644 --- a/frontend/src/pages/Alarms/index.tsx +++ b/frontend/src/pages/Alarms/index.tsx @@ -1,7 +1,11 @@ import { useEffect, useState } from 'react'; -import { Card, Table, Tag, Button, Tabs, Modal, Form, Input, Select, InputNumber, Space, message } from 'antd'; -import { PlusOutlined, CheckOutlined, ToolOutlined } from '@ant-design/icons'; -import { getAlarmEvents, getAlarmRules, createAlarmRule, acknowledgeAlarm, resolveAlarm } from '../../services/api'; +import { Card, Table, Tag, Button, Tabs, Modal, Form, Input, Select, InputNumber, Space, Switch, Drawer, Row, Col, Statistic, message } from 'antd'; +import { PlusOutlined, CheckOutlined, ToolOutlined, HistoryOutlined } from '@ant-design/icons'; +import ReactECharts from 'echarts-for-react'; +import { + getAlarmEvents, getAlarmRules, createAlarmRule, acknowledgeAlarm, resolveAlarm, + getAlarmAnalytics, getTopAlarmDevices, getAlarmMttr, toggleAlarmRule, getAlarmRuleHistory, +} from '../../services/api'; const severityMap: Record = { critical: { color: 'red', text: '紧急' }, @@ -15,12 +19,122 @@ const statusMap: Record = { resolved: { color: 'green', text: '已解决' }, }; +function AlarmAnalyticsTab() { + const [analytics, setAnalytics] = useState(null); + const [topDevices, setTopDevices] = useState([]); + const [mttr, setMttr] = useState({}); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadAnalytics(); + }, []); + + const loadAnalytics = async () => { + setLoading(true); + try { + const [ana, top, mt] = await Promise.all([ + getAlarmAnalytics({}), + getTopAlarmDevices({}), + getAlarmMttr({}), + ]); + setAnalytics(ana); + setTopDevices(top as any[]); + setMttr(mt); + } catch { + message.error('加载告警分析数据失败'); + } finally { + setLoading(false); + } + }; + + const trendOption = analytics ? { + tooltip: { trigger: 'axis' }, + legend: { data: ['紧急', '重要', '一般'] }, + grid: { top: 50, right: 40, bottom: 30, left: 60 }, + xAxis: { type: 'category', data: analytics.daily_trend.map((d: any) => d.date) }, + yAxis: { type: 'value', name: '次数' }, + series: [ + { name: '紧急', type: 'line', smooth: true, data: analytics.daily_trend.map((d: any) => d.critical), lineStyle: { color: '#f5222d' }, itemStyle: { color: '#f5222d' } }, + { name: '重要', type: 'line', smooth: true, data: analytics.daily_trend.map((d: any) => d.major), lineStyle: { color: '#fa8c16' }, itemStyle: { color: '#fa8c16' } }, + { name: '一般', type: 'line', smooth: true, data: analytics.daily_trend.map((d: any) => d.warning), lineStyle: { color: '#fadb14' }, itemStyle: { color: '#fadb14' } }, + ], + } : {}; + + const topDevicesOption = { + tooltip: { trigger: 'axis' }, + grid: { top: 20, right: 40, bottom: 30, left: 120 }, + xAxis: { type: 'value', name: '告警次数' }, + yAxis: { type: 'category', data: [...topDevices].reverse().map(d => d.device_name) }, + series: [{ + type: 'bar', + data: [...topDevices].reverse().map(d => d.alarm_count), + itemStyle: { color: '#fa8c16' }, + }], + }; + + const totals = analytics?.totals || {}; + const pieOption = { + tooltip: { trigger: 'item' }, + series: [{ + type: 'pie', + radius: ['40%', '70%'], + data: [ + { value: totals.critical || 0, name: '紧急', itemStyle: { color: '#f5222d' } }, + { value: totals.major || 0, name: '重要', itemStyle: { color: '#fa8c16' } }, + { value: totals.warning || 0, name: '一般', itemStyle: { color: '#fadb14' } }, + ], + }], + }; + + return ( +
+ + {(['critical', 'major', 'warning'] as const).map(sev => ( + + + +
+ 已解决 {mttr[sev]?.count || 0} 条 +
+
+ + ))} +
+ + + + + {analytics && } + + + + + + + + + + + + +
+ ); +} + export default function Alarms() { const [events, setEvents] = useState({ total: 0, items: [] }); const [rules, setRules] = useState([]); const [loading, setLoading] = useState(true); const [showRuleModal, setShowRuleModal] = useState(false); const [form] = Form.useForm(); + const [historyDrawer, setHistoryDrawer] = useState<{ visible: boolean; ruleId: number; ruleName: string }>({ visible: false, ruleId: 0, ruleName: '' }); + const [historyData, setHistoryData] = useState({ total: 0, items: [] }); + const [historyLoading, setHistoryLoading] = useState(false); useEffect(() => { loadData(); }, []); @@ -60,6 +174,24 @@ export default function Alarms() { } catch { message.error('规则创建失败'); } }; + const handleToggleRule = async (ruleId: number) => { + try { + await toggleAlarmRule(ruleId); + message.success('状态已更新'); + loadData(); + } catch { message.error('切换状态失败'); } + }; + + const handleShowHistory = async (ruleId: number, ruleName: string) => { + setHistoryDrawer({ visible: true, ruleId, ruleName }); + setHistoryLoading(true); + try { + const res = await getAlarmRuleHistory(ruleId, { page: 1, page_size: 20 }); + setHistoryData(res); + } catch { message.error('加载规则历史失败'); } + finally { setHistoryLoading(false); } + }; + const eventColumns = [ { title: '级别', dataIndex: 'severity', width: 80, render: (s: string) => { const sv = severityMap[s] || { color: 'default', text: s }; @@ -87,7 +219,31 @@ export default function Alarms() { { title: '条件', dataIndex: 'condition' }, { title: '阈值', dataIndex: 'threshold' }, { title: '级别', dataIndex: 'severity', render: (s: string) => {severityMap[s]?.text} }, - { title: '状态', dataIndex: 'is_active', render: (v: boolean) => {v ? '启用' : '禁用'} }, + { + title: '启用', + dataIndex: 'is_active', + width: 80, + render: (v: boolean, r: any) => ( + handleToggleRule(r.id)} size="small" /> + ), + }, + { + title: '操作', + key: 'action', + width: 100, + render: (_: any, r: any) => ( + + ), + }, + ]; + + const historyColumns = [ + { title: '级别', dataIndex: 'severity', width: 80, render: (s: string) => {severityMap[s]?.text} }, + { title: '告警标题', dataIndex: 'title' }, + { title: '触发值', dataIndex: 'value', render: (v: number) => v?.toFixed(2) }, + { title: '状态', dataIndex: 'status', render: (s: string) => {statusMap[s]?.text} }, + { title: '触发时间', dataIndex: 'triggered_at', width: 180 }, + { title: '解决时间', dataIndex: 'resolved_at', width: 180 }, ]; return ( @@ -106,6 +262,7 @@ export default function Alarms() { loading={loading} size="small" /> )}, + { key: 'analytics', label: '分析', children: }, ]} /> setShowRuleModal(false)} @@ -141,6 +298,17 @@ export default function Alarms() { + + setHistoryDrawer({ visible: false, ruleId: 0, ruleName: '' })} + width={700} + > + + ); } diff --git a/frontend/src/pages/Analysis/CostAnalysis.tsx b/frontend/src/pages/Analysis/CostAnalysis.tsx new file mode 100644 index 0000000..eb1135a --- /dev/null +++ b/frontend/src/pages/Analysis/CostAnalysis.tsx @@ -0,0 +1,245 @@ +import { useEffect, useState } from 'react'; +import { Card, Row, Col, DatePicker, Select, Statistic, Button, Space, message } from 'antd'; +import { ArrowUpOutlined, ArrowDownOutlined, DownloadOutlined } from '@ant-design/icons'; +import ReactECharts from 'echarts-for-react'; +import dayjs, { type Dayjs } from 'dayjs'; +import { getCostSummary, getCostComparison, getCostBreakdown } from '../../services/api'; + +const { RangePicker } = DatePicker; + +export default function CostAnalysis() { + const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([ + dayjs().subtract(30, 'day'), dayjs(), + ]); + const [groupBy, setGroupBy] = useState('day'); + const [comparison, setComparison] = useState(null); + const [summary, setSummary] = useState([]); + const [breakdown, setBreakdown] = useState(null); + const [loading, setLoading] = useState(false); + + const loadData = async () => { + setLoading(true); + try { + const start = dateRange[0].format('YYYY-MM-DD'); + const end = dateRange[1].format('YYYY-MM-DD'); + const [comp, sum, bkd] = await Promise.all([ + getCostComparison({ energy_type: 'electricity', period: 'month' }), + getCostSummary({ start_date: start, end_date: end, group_by: groupBy, energy_type: 'electricity' }), + getCostBreakdown({ start_date: start, end_date: end, energy_type: 'electricity' }), + ]); + setComparison(comp); + setSummary(sum as any[]); + setBreakdown(bkd); + } catch (e) { + console.error(e); + message.error('加载费用数据失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadData(); + }, [groupBy]); + + // KPI calculations + const todayCost = comparison?.current || 0; + const monthCost = comparison?.current || 0; + const yearCost = comparison?.yoy || 0; + const momChange = comparison?.mom_change || 0; + const yoyChange = comparison?.yoy_change || 0; + + // Breakdown pie chart + const breakdownPieOption = { + tooltip: { trigger: 'item', formatter: '{b}: {c} 元 ({d}%)' }, + legend: { bottom: 10 }, + series: [{ + type: 'pie', + radius: ['40%', '70%'], + avoidLabelOverlap: false, + itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 }, + label: { show: true, formatter: '{b}\n{d}%' }, + data: (breakdown?.periods || []).map((p: any) => ({ + value: p.cost, + name: p.period_label || p.period_name, + itemStyle: { + color: p.period_name === 'peak' || p.period_name === 'sharp' ? '#f5222d' + : p.period_name === 'valley' || p.period_name === 'off_peak' ? '#52c41a' + : p.period_name === 'flat' ? '#1890ff' : '#faad14', + }, + })), + }], + }; + + // Cost trend line chart + const trendChartOption = { + tooltip: { trigger: 'axis' }, + legend: { data: ['费用(元)', '用电量(kWh)'] }, + grid: { top: 50, right: 60, bottom: 30, left: 60 }, + xAxis: { + type: 'category', + data: summary.map((d: any) => { + if (d.date) return dayjs(d.date).format('MM/DD'); + if (d.period) return d.period; + if (d.device_name) return d.device_name; + return ''; + }), + }, + yAxis: [ + { type: 'value', name: '元', position: 'left' }, + { type: 'value', name: 'kWh', position: 'right' }, + ], + series: [ + { + name: '费用(元)', + type: groupBy === 'device' ? 'bar' : 'line', + smooth: true, + data: summary.map((d: any) => d.cost || 0), + lineStyle: { color: '#f5222d' }, + itemStyle: { color: '#f5222d' }, + yAxisIndex: 0, + }, + { + name: '用电量(kWh)', + type: groupBy === 'device' ? 'bar' : 'line', + smooth: true, + data: summary.map((d: any) => d.consumption || 0), + lineStyle: { color: '#1890ff' }, + itemStyle: { color: '#1890ff' }, + yAxisIndex: 1, + }, + ], + }; + + // Cost by building bar chart (using device grouping) + const [deviceSummary, setDeviceSummary] = useState([]); + useEffect(() => { + const loadDeviceSummary = async () => { + try { + const start = dateRange[0].format('YYYY-MM-DD'); + const end = dateRange[1].format('YYYY-MM-DD'); + const data = await getCostSummary({ + start_date: start, end_date: end, group_by: 'device', energy_type: 'electricity', + }); + setDeviceSummary(data as any[]); + } catch (e) { + console.error(e); + } + }; + loadDeviceSummary(); + }, [dateRange]); + + const deviceBarOption = { + tooltip: { trigger: 'axis' }, + grid: { top: 30, right: 20, bottom: 60, left: 60 }, + xAxis: { + type: 'category', + data: deviceSummary.map((d: any) => d.device_name || `#${d.device_id}`), + axisLabel: { rotate: 30, fontSize: 11 }, + }, + yAxis: { type: 'value', name: '元' }, + series: [{ + type: 'bar', + data: deviceSummary.map((d: any) => d.cost || 0), + itemStyle: { + color: { + type: 'linear', x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: '#1890ff' }, + { offset: 1, color: '#69c0ff' }, + ], + }, + }, + barMaxWidth: 40, + }], + }; + + const handleExport = () => { + const rows = summary.map((d: any) => { + const label = d.date || d.period || d.device_name || ''; + return `${label},${d.consumption || 0},${d.cost || 0}`; + }); + const csv = '\ufeff日期/分组,用电量(kWh),费用(元)\n' + rows.join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `cost_analysis_${dateRange[0].format('YYYYMMDD')}_${dateRange[1].format('YYYYMMDD')}.csv`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + message.success('导出成功'); + }; + + return ( +
+ {/* Controls */} + + + dates && setDateRange(dates as [Dayjs, Dayjs])} + /> + + + + + + + + + +
+ + + ); +} diff --git a/frontend/src/pages/Analysis/MomAnalysis.tsx b/frontend/src/pages/Analysis/MomAnalysis.tsx new file mode 100644 index 0000000..236ec6c --- /dev/null +++ b/frontend/src/pages/Analysis/MomAnalysis.tsx @@ -0,0 +1,130 @@ +import { useEffect, useState } from 'react'; +import { Card, Row, Col, Select, Space, Statistic, message } from 'antd'; +import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'; +import ReactECharts from 'echarts-for-react'; +import { getEnergyMom } from '../../services/api'; + +interface MomItem { + label: string; + current_period: number; + previous_period: number; + change_pct: number; +} + +interface MomData { + items: MomItem[]; + total_current: number; + total_previous: number; + total_change_pct: number; +} + +export default function MomAnalysis() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [period, setPeriod] = useState('month'); + const [energyType, setEnergyType] = useState('electricity'); + + const loadData = async () => { + setLoading(true); + try { + const res = await getEnergyMom({ period, energy_type: energyType }); + setData(res as MomData); + } catch { + message.error('加载环比数据失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadData(); }, [period, energyType]); + + const periodLabels: Record = { + month: ['本月', '上月'], + week: ['本周', '上周'], + day: ['今日', '昨日'], + }; + const [curLabel, prevLabel] = periodLabels[period] || ['当前', '上期']; + + const chartOption = data ? { + tooltip: { trigger: 'axis' }, + legend: { data: [curLabel, prevLabel] }, + grid: { top: 50, right: 40, bottom: 30, left: 60 }, + xAxis: { type: 'category', data: data.items.map(d => d.label) }, + yAxis: { type: 'value', name: 'kWh' }, + series: [ + { + name: curLabel, + type: 'line', + smooth: true, + data: data.items.map(d => d.current_period), + lineStyle: { color: '#1890ff' }, + itemStyle: { color: '#1890ff' }, + }, + { + name: prevLabel, + type: 'line', + smooth: true, + data: data.items.map(d => d.previous_period), + lineStyle: { color: '#faad14', type: 'dashed' }, + itemStyle: { color: '#faad14' }, + }, + ], + } : {}; + + const changePct = data?.total_change_pct || 0; + + return ( +
+ + + 对比周期: + + + + + +
+ + + + + + + + + + + + = 0 ? : } + valueStyle={{ color: changePct >= 0 ? '#f5222d' : '#52c41a' }} + precision={1} + /> + + + + + + {data && } + + + ); +} diff --git a/frontend/src/pages/Analysis/SubitemAnalysis.tsx b/frontend/src/pages/Analysis/SubitemAnalysis.tsx new file mode 100644 index 0000000..a01c86f --- /dev/null +++ b/frontend/src/pages/Analysis/SubitemAnalysis.tsx @@ -0,0 +1,222 @@ +import { useEffect, useState } from 'react'; +import { Card, Row, Col, DatePicker, Checkbox, Table, message } from 'antd'; +import ReactECharts from 'echarts-for-react'; +import dayjs, { type Dayjs } from 'dayjs'; +import api from '../../services/api'; + +const { RangePicker } = DatePicker; + +interface Category { + id: number; + name: string; + code: string; + color: string; + children?: Category[]; +} + +interface ByCategory { + id: number; + name: string; + code: string; + color: string; + consumption: number; + percentage: number; +} + +interface RankingItem { + name: string; + color: string; + consumption: number; +} + +interface TrendItem { + date: string; + category: string; + color: string; + consumption: number; +} + +export default function SubitemAnalysis() { + const [categories, setCategories] = useState([]); + const [flatCategories, setFlatCategories] = useState([]); + const [selectedCodes, setSelectedCodes] = useState([]); + const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([ + dayjs().subtract(30, 'day'), dayjs(), + ]); + const [byCategory, setByCategory] = useState([]); + const [ranking, setRanking] = useState([]); + const [trend, setTrend] = useState([]); + const [loading, setLoading] = useState(false); + + const flatten = (cats: Category[]): Category[] => { + const result: Category[] = []; + const walk = (list: Category[]) => { + for (const c of list) { + result.push(c); + if (c.children) walk(c.children); + } + }; + walk(cats); + return result; + }; + + useEffect(() => { + (async () => { + try { + const cats = await api.get('/energy/categories') as any as Category[]; + setCategories(cats); + const flat = flatten(cats); + setFlatCategories(flat); + setSelectedCodes(flat.map(c => c.code)); + } catch { + message.error('加载分项类别失败'); + } + })(); + }, []); + + useEffect(() => { + if (selectedCodes.length > 0) loadData(); + }, [selectedCodes, dateRange]); + + const loadData = async () => { + setLoading(true); + const params = { + start_date: dateRange[0].format('YYYY-MM-DD'), + end_date: dateRange[1].format('YYYY-MM-DD'), + energy_type: 'electricity', + }; + try { + const [byCat, rank, trendData] = await Promise.all([ + api.get('/energy/by-category', { params }), + api.get('/energy/category-ranking', { params }), + api.get('/energy/category-trend', { params }), + ]); + setByCategory((byCat as any[]).filter(c => selectedCodes.includes(c.code))); + setRanking((rank as any[]).filter(c => selectedCodes.includes(c.name) || true)); + setTrend(trendData as any[]); + } catch { + message.error('加载分项数据失败'); + } finally { + setLoading(false); + } + }; + + const pieOption = { + tooltip: { trigger: 'item', formatter: '{b}: {c} kWh ({d}%)' }, + legend: { orient: 'vertical' as const, right: 10, top: 'center' }, + series: [{ + type: 'pie', + radius: ['40%', '70%'], + avoidLabelOverlap: true, + itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 }, + label: { show: true, formatter: '{b}\n{d}%' }, + data: byCategory.map(c => ({ + name: c.name, value: c.consumption, + itemStyle: c.color ? { color: c.color } : undefined, + })), + }], + }; + + const barOption = { + tooltip: { trigger: 'axis' }, + grid: { top: 10, right: 30, bottom: 30, left: 100 }, + xAxis: { type: 'value' as const, name: 'kWh' }, + yAxis: { + type: 'category' as const, + data: [...ranking].reverse().map(r => r.name), + }, + series: [{ + type: 'bar', + data: [...ranking].reverse().map(r => ({ + value: r.consumption, + itemStyle: r.color ? { color: r.color } : undefined, + })), + }], + }; + + // Group trend data by category for line chart + const trendCategories = [...new Set(trend.map(t => t.category))]; + const trendDates = [...new Set(trend.map(t => t.date))].sort(); + const colorMap: Record = {}; + trend.forEach(t => { if (t.color) colorMap[t.category] = t.color; }); + + const lineOption = { + tooltip: { trigger: 'axis' }, + legend: { data: trendCategories }, + grid: { top: 40, right: 20, bottom: 30, left: 60 }, + xAxis: { + type: 'category' as const, + data: trendDates.map(d => dayjs(d).format('MM/DD')), + }, + yAxis: { type: 'value' as const, name: 'kWh' }, + series: trendCategories.map(cat => ({ + name: cat, + type: 'line', + smooth: true, + data: trendDates.map(d => { + const item = trend.find(t => t.date === d && t.category === cat); + return item ? item.consumption : 0; + }), + lineStyle: colorMap[cat] ? { color: colorMap[cat] } : undefined, + itemStyle: colorMap[cat] ? { color: colorMap[cat] } : undefined, + })), + }; + + const tableColumns = [ + { title: '分项名称', dataIndex: 'name' }, + { title: '用量 (kWh)', dataIndex: 'consumption', render: (v: number) => v?.toFixed(2) }, + { title: '占比 (%)', dataIndex: 'percentage', render: (v: number) => v?.toFixed(1) }, + ]; + + return ( +
+ + +
+ 日期范围: + dates && setDateRange(dates as [Dayjs, Dayjs])} + /> + + + 分项类别: + setSelectedCodes(vals as string[])} + options={flatCategories.map(c => ({ label: c.name, value: c.code }))} + /> + + + + + + + + + + + + + + + + + + + + + + +
+ + + ); +} diff --git a/frontend/src/pages/Analysis/YoyAnalysis.tsx b/frontend/src/pages/Analysis/YoyAnalysis.tsx new file mode 100644 index 0000000..8d16a37 --- /dev/null +++ b/frontend/src/pages/Analysis/YoyAnalysis.tsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from 'react'; +import { Card, Table, DatePicker, Select, Space, message } from 'antd'; +import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'; +import ReactECharts from 'echarts-for-react'; +import dayjs from 'dayjs'; +import { getEnergyYoy } from '../../services/api'; + +interface YoyItem { + month: number; + current_year: number; + previous_year: number; + change_pct: number; +} + +export default function YoyAnalysis() { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [year, setYear] = useState(dayjs().year()); + const [energyType, setEnergyType] = useState('electricity'); + + const loadData = async () => { + setLoading(true); + try { + const res = await getEnergyYoy({ year, energy_type: energyType }); + setData(res as YoyItem[]); + } catch { + message.error('加载同比数据失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadData(); }, [year, energyType]); + + const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; + + const chartOption = { + tooltip: { trigger: 'axis' }, + legend: { data: [`${year}年`, `${year - 1}年`] }, + grid: { top: 50, right: 40, bottom: 30, left: 60 }, + xAxis: { type: 'category', data: months }, + yAxis: { type: 'value', name: 'kWh' }, + series: [ + { + name: `${year}年`, + type: 'bar', + data: data.map(d => d.current_year), + itemStyle: { color: '#1890ff' }, + }, + { + name: `${year - 1}年`, + type: 'bar', + data: data.map(d => d.previous_year), + itemStyle: { color: '#faad14' }, + }, + ], + }; + + const columns = [ + { title: '月份', dataIndex: 'month', render: (v: number) => `${v}月` }, + { title: `${year}年 (kWh)`, dataIndex: 'current_year', render: (v: number) => v?.toFixed(2) }, + { title: `${year - 1}年 (kWh)`, dataIndex: 'previous_year', render: (v: number) => v?.toFixed(2) }, + { + title: '同比变化', + dataIndex: 'change_pct', + render: (v: number) => ( + 0 ? '#f5222d' : v < 0 ? '#52c41a' : '#666' }}> + {v > 0 ? : v < 0 ? : null} + {' '}{Math.abs(v).toFixed(1)}% + + ), + }, + ]; + + const yearOptions = []; + for (let y = dayjs().year(); y >= dayjs().year() - 5; y--) { + yearOptions.push({ label: `${y}年`, value: y }); + } + + return ( +
+ + + 年份: + + + + + + + + + +
+ + + ); +} diff --git a/frontend/src/pages/Analysis/index.tsx b/frontend/src/pages/Analysis/index.tsx index dbf12c8..b24a1ca 100644 --- a/frontend/src/pages/Analysis/index.tsx +++ b/frontend/src/pages/Analysis/index.tsx @@ -4,6 +4,11 @@ import { ArrowUpOutlined, ArrowDownOutlined, DownloadOutlined, SwapOutlined } fr import ReactECharts from 'echarts-for-react'; import dayjs, { type Dayjs } from 'dayjs'; import { getEnergyHistory, getEnergyComparison, getEnergyDailySummary, exportEnergyData } from '../../services/api'; +import LossAnalysis from './LossAnalysis'; +import YoyAnalysis from './YoyAnalysis'; +import MomAnalysis from './MomAnalysis'; +import CostAnalysis from './CostAnalysis'; +import SubitemAnalysis from './SubitemAnalysis'; const { RangePicker } = DatePicker; @@ -319,6 +324,11 @@ export default function Analysis() { items={[ { key: 'overview', label: '能耗概览', children: overviewContent }, { key: 'comparison', label: '数据对比', children: }, + { key: 'loss', label: '损耗分析', children: }, + { key: 'yoy', label: '同比分析', children: }, + { key: 'mom', label: '环比分析', children: }, + { key: 'cost', label: '费用分析', children: }, + { key: 'subitem', label: '分项分析', children: }, ]} /> diff --git a/frontend/src/pages/Charging/Dashboard.tsx b/frontend/src/pages/Charging/Dashboard.tsx new file mode 100644 index 0000000..5bd2b49 --- /dev/null +++ b/frontend/src/pages/Charging/Dashboard.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from 'react'; +import { Card, Row, Col, Statistic, message } from 'antd'; +import { DollarOutlined, ThunderboltOutlined, CarOutlined, DashboardOutlined } from '@ant-design/icons'; +import { getChargingDashboard } from '../../services/api'; +import ReactECharts from 'echarts-for-react'; + +export default function ChargingDashboard() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadDashboard(); + }, []); + + const loadDashboard = async () => { + setLoading(true); + try { + const res = await getChargingDashboard(); + setData(res); + } catch { + message.error('加载充电总览失败'); + } finally { + setLoading(false); + } + }; + + const pileStatusData = data ? [ + { type: '空闲', value: data.pile_status?.idle || 0 }, + { type: '充电中', value: data.pile_status?.charging || 0 }, + { type: '故障', value: data.pile_status?.fault || 0 }, + { type: '离线', value: data.pile_status?.offline || 0 }, + ].filter(d => d.value > 0) : []; + + const pileStatusColors: Record = { + '空闲': '#52c41a', + '充电中': '#1890ff', + '故障': '#ff4d4f', + '离线': '#d9d9d9', + }; + + const revenueLineOption = { + tooltip: { trigger: 'axis' as const }, + xAxis: { + type: 'category' as const, + data: (data?.revenue_trend || []).map((d: any) => d.date), + axisLabel: { rotate: 45 }, + }, + yAxis: { type: 'value' as const, name: '营收 (元)' }, + series: [{ + type: 'line', + data: (data?.revenue_trend || []).map((d: any) => d.revenue), + smooth: true, + symbolSize: 6, + }], + grid: { left: 60, right: 20, bottom: 60, top: 30 }, + }; + + const pieOption = { + tooltip: { trigger: 'item' as const }, + series: [{ + type: 'pie', + radius: ['40%', '70%'], + data: pileStatusData.map(d => ({ + name: d.type, + value: d.value, + itemStyle: { color: pileStatusColors[d.type] || '#d9d9d9' }, + })), + label: { formatter: '{b} {c}' }, + }], + }; + + const barOption = { + tooltip: { trigger: 'axis' as const }, + xAxis: { type: 'value' as const, name: '营收 (元)' }, + yAxis: { + type: 'category' as const, + data: (data?.station_ranking || []).map((d: any) => d.station), + }, + series: [{ + type: 'bar', + data: (data?.station_ranking || []).map((d: any) => d.revenue), + label: { show: true, position: 'right' }, + }], + grid: { left: 120, right: 40, bottom: 20, top: 20 }, + }; + + return ( +
+ +
+ + } + precision={2} + valueStyle={{ color: '#cf1322' }} + /> + + + + + } + precision={1} + valueStyle={{ color: '#1890ff' }} + /> + + + + + } + valueStyle={{ color: '#52c41a' }} + /> + + + + + } + suffix="%" + valueStyle={{ color: '#faad14' }} + /> + + + + + + + + {data?.revenue_trend?.length > 0 ? ( + + ) : ( +
暂无数据
+ )} +
+ + + + {pileStatusData.length > 0 ? ( + + ) : ( +
暂无数据
+ )} +
+ + + + + + + {data?.station_ranking?.length > 0 ? ( + + ) : ( +
暂无数据
+ )} +
+ + + + ); +} diff --git a/frontend/src/pages/Charging/Orders.tsx b/frontend/src/pages/Charging/Orders.tsx new file mode 100644 index 0000000..b3492db --- /dev/null +++ b/frontend/src/pages/Charging/Orders.tsx @@ -0,0 +1,201 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Card, Table, Tag, Button, Tabs, Space, DatePicker, Select, message } from 'antd'; +import { SyncOutlined, WarningOutlined } from '@ant-design/icons'; +import { getChargingOrders, getChargingRealtimeOrders, getChargingAbnormalOrders, settleChargingOrder } from '../../services/api'; + +const { RangePicker } = DatePicker; + +const orderStatusMap: Record = { + charging: { color: 'processing', text: '充电中' }, + pending_pay: { color: 'warning', text: '待支付' }, + completed: { color: 'success', text: '已完成' }, + failed: { color: 'error', text: '失败' }, + refunded: { color: 'default', text: '已退款' }, +}; + +const formatDuration = (seconds: number | null) => { + if (!seconds) return '-'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return h > 0 ? `${h}时${m}分` : `${m}分`; +}; + +export default function Orders() { + return ( + }, + { key: 'history', label: '历史订单', children: }, + { key: 'abnormal', label: '异常订单', children: }, + ]} + /> + ); +} + +function RealtimeOrders() { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + + const loadData = async () => { + setLoading(true); + try { + const res = await getChargingRealtimeOrders(); + setData(res as any[]); + } catch { message.error('加载实时充电失败'); } + finally { setLoading(false); } + }; + + useEffect(() => { loadData(); }, []); + + const columns = [ + { title: '订单号', dataIndex: 'order_no', width: 160 }, + { title: '充电站', dataIndex: 'station_name', width: 150, ellipsis: true }, + { title: '充电桩', dataIndex: 'pile_name', width: 120 }, + { title: '用户', dataIndex: 'user_name', width: 100 }, + { title: '车牌', dataIndex: 'car_no', width: 100 }, + { title: '开始时间', dataIndex: 'start_time', width: 170 }, + { title: '时长', dataIndex: 'charge_duration', width: 80, render: formatDuration }, + { title: '充电量(kWh)', dataIndex: 'energy', width: 110, render: (v: number) => v != null ? v.toFixed(2) : '-' }, + { title: '起始SOC', dataIndex: 'start_soc', width: 90, render: (v: number) => v != null ? `${v}%` : '-' }, + { title: '当前SOC', dataIndex: 'end_soc', width: 90, render: (v: number) => v != null ? `${v}%` : '-' }, + { title: '状态', dataIndex: 'order_status', width: 90, render: () => ( + } color="processing">充电中 + )}, + ]; + + return ( + 刷新}> +
+ + ); +} + +function HistoryOrders() { + const [data, setData] = useState({ total: 0, items: [] }); + const [loading, setLoading] = useState(true); + const [filters, setFilters] = useState>({ page: 1, page_size: 20 }); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const cleanQuery: Record = {}; + Object.entries(filters).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') cleanQuery[k] = v; + }); + const res = await getChargingOrders(cleanQuery); + setData(res as any); + } catch { message.error('加载订单失败'); } + finally { setLoading(false); } + }, [filters]); + + useEffect(() => { loadData(); }, [filters, loadData]); + + const handleDateChange = (_: any, dates: [string, string]) => { + setFilters(prev => ({ + ...prev, + start_date: dates[0] || undefined, + end_date: dates[1] || undefined, + page: 1, + })); + }; + + const columns = [ + { title: '订单号', dataIndex: 'order_no', width: 160 }, + { title: '充电站', dataIndex: 'station_name', width: 150, ellipsis: true }, + { title: '充电桩', dataIndex: 'pile_name', width: 120 }, + { title: '用户', dataIndex: 'user_name', width: 100 }, + { title: '车牌', dataIndex: 'car_no', width: 100 }, + { title: '充电量(kWh)', dataIndex: 'energy', width: 110, render: (v: number) => v != null ? v.toFixed(2) : '-' }, + { title: '时长', dataIndex: 'charge_duration', width: 80, render: formatDuration }, + { title: '电费(元)', dataIndex: 'elec_amt', width: 90, render: (v: number) => v != null ? v.toFixed(2) : '-' }, + { title: '服务费(元)', dataIndex: 'serve_amt', width: 100, render: (v: number) => v != null ? v.toFixed(2) : '-' }, + { title: '实付(元)', dataIndex: 'paid_price', width: 90, render: (v: number) => v != null ? v.toFixed(2) : '-' }, + { title: '状态', dataIndex: 'order_status', width: 90, render: (s: string) => { + const st = orderStatusMap[s] || { color: 'default', text: s || '-' }; + return {st.text}; + }}, + { title: '创建时间', dataIndex: 'created_at', width: 170 }, + ]; + + return ( + + +
`共 ${total} 条订单`, + onChange: (page, pageSize) => setFilters(prev => ({ ...prev, page, page_size: pageSize })), + }} + /> + + ); +} + +function AbnormalOrders() { + const [data, setData] = useState({ total: 0, items: [] }); + const [loading, setLoading] = useState(true); + const [filters, setFilters] = useState>({ page: 1, page_size: 20 }); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const res = await getChargingAbnormalOrders(filters); + setData(res as any); + } catch { message.error('加载异常订单失败'); } + finally { setLoading(false); } + }, [filters]); + + useEffect(() => { loadData(); }, [filters, loadData]); + + const handleSettle = async (id: number) => { + try { + await settleChargingOrder(id); + message.success('手动结算成功'); + loadData(); + } catch { message.error('结算失败'); } + }; + + const columns = [ + { title: '订单号', dataIndex: 'order_no', width: 160 }, + { title: '充电站', dataIndex: 'station_name', width: 150, ellipsis: true }, + { title: '充电桩', dataIndex: 'pile_name', width: 120 }, + { title: '用户', dataIndex: 'user_name', width: 100 }, + { title: '充电量(kWh)', dataIndex: 'energy', width: 110, render: (v: number) => v != null ? v.toFixed(2) : '-' }, + { title: '状态', dataIndex: 'order_status', width: 90, render: (s: string) => { + const st = orderStatusMap[s] || { color: 'default', text: s || '-' }; + return } color={st.color}>{st.text}; + }}, + { title: '异常原因', dataIndex: 'abno_cause', width: 200, ellipsis: true }, + { title: '创建时间', dataIndex: 'created_at', width: 170 }, + { title: '操作', key: 'action', width: 100, render: (_: any, record: any) => ( + record.order_status === 'failed' && ( + + ) + )}, + ]; + + return ( + +
`共 ${total} 条异常订单`, + onChange: (page, pageSize) => setFilters(prev => ({ ...prev, page, page_size: pageSize })), + }} + /> + + ); +} diff --git a/frontend/src/pages/Charging/Piles.tsx b/frontend/src/pages/Charging/Piles.tsx new file mode 100644 index 0000000..d558782 --- /dev/null +++ b/frontend/src/pages/Charging/Piles.tsx @@ -0,0 +1,203 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Card, Table, Tag, Button, Modal, Form, Input, Select, InputNumber, Space, message } from 'antd'; +import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; +import { getChargingPiles, createChargingPile, updateChargingPile, deleteChargingPile, getChargingStations, getChargingBrands } from '../../services/api'; + +const workStatusMap: Record = { + idle: { color: 'green', text: '空闲' }, + charging: { color: 'blue', text: '充电中' }, + fault: { color: 'red', text: '故障' }, + offline: { color: 'default', text: '离线' }, +}; + +const typeOptions = [ + { label: '交流慢充', value: 'AC_slow' }, + { label: '直流快充', value: 'DC_fast' }, + { label: '直流超充', value: 'DC_superfast' }, +]; + +const connectorOptions = [ + { label: 'GB/T', value: 'GB_T' }, + { label: 'CCS', value: 'CCS' }, + { label: 'CHAdeMO', value: 'CHAdeMO' }, +]; + +export default function Piles() { + const [data, setData] = useState({ total: 0, items: [] }); + const [stations, setStations] = useState([]); + const [brands, setBrands] = useState([]); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + const [filters, setFilters] = useState>({ page: 1, page_size: 20 }); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const cleanQuery: Record = {}; + Object.entries(filters).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') cleanQuery[k] = v; + }); + const res = await getChargingPiles(cleanQuery); + setData(res as any); + } catch { message.error('加载充电桩失败'); } + finally { setLoading(false); } + }, [filters]); + + const loadMeta = async () => { + try { + const [st, br] = await Promise.all([ + getChargingStations({ page_size: 100 }), + getChargingBrands(), + ]); + setStations((st as any).items || []); + setBrands(br as any[]); + } catch {} + }; + + useEffect(() => { loadMeta(); }, []); + useEffect(() => { loadData(); }, [filters, loadData]); + + const handleFilterChange = (key: string, value: any) => { + setFilters(prev => ({ ...prev, [key]: value, page: 1 })); + }; + + const openAddModal = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ status: 'active', work_status: 'offline' }); + setShowModal(true); + }; + + const openEditModal = (record: any) => { + setEditing(record); + form.setFieldsValue(record); + setShowModal(true); + }; + + const handleSubmit = async (values: any) => { + try { + if (editing) { + await updateChargingPile(editing.id, values); + message.success('充电桩更新成功'); + } else { + await createChargingPile(values); + message.success('充电桩创建成功'); + } + setShowModal(false); + form.resetFields(); + loadData(); + } catch (e: any) { + message.error(e?.detail || '操作失败'); + } + }; + + const handleDelete = async (id: number) => { + try { + await deleteChargingPile(id); + message.success('已停用'); + loadData(); + } catch { message.error('操作失败'); } + }; + + const columns = [ + { title: '终端编码', dataIndex: 'encoding', width: 140 }, + { title: '名称', dataIndex: 'name', width: 150, ellipsis: true }, + { title: '所属充电站', dataIndex: 'station_id', width: 150, render: (id: number) => { + const s = stations.find((st: any) => st.id === id); + return s ? s.name : id; + }}, + { title: '类型', dataIndex: 'type', width: 100, render: (v: string) => typeOptions.find(o => o.value === v)?.label || v || '-' }, + { title: '额定功率(kW)', dataIndex: 'rated_power_kw', width: 120, render: (v: number) => v != null ? v : '-' }, + { title: '品牌', dataIndex: 'brand', width: 100 }, + { title: '型号', dataIndex: 'model', width: 100 }, + { title: '接口类型', dataIndex: 'connector_type', width: 100 }, + { title: '工作状态', dataIndex: 'work_status', width: 100, render: (s: string) => { + const st = workStatusMap[s] || { color: 'default', text: s || '-' }; + return {st.text}; + }}, + { title: '状态', dataIndex: 'status', width: 80, render: (s: string) => ( + {s === 'active' ? '启用' : '停用'} + )}, + { title: '操作', key: 'action', width: 150, fixed: 'right' as const, render: (_: any, record: any) => ( + + + + + )}, + ]; + + return ( + } onClick={openAddModal}>添加充电桩 + }> + + handleFilterChange('type', v)} /> +
`共 ${total} 个充电桩`, + onChange: (page, pageSize) => setFilters(prev => ({ ...prev, page, page_size: pageSize })), + }} + /> + + { setShowModal(false); form.resetFields(); }} + onOk={() => form.submit()} + okText={editing ? '保存' : '创建'} + cancelText="取消" + width={640} + destroyOnClose + > +
+ + + + + + + + ({ label: b.brand_name, value: b.brand_name }))} /> + + + + + + + + +
; + }; + + return ( + } onClick={openAddModal}>新建策略 + }> +
+ + { setShowModal(false); form.resetFields(); }} + onOk={() => form.submit()} + okText={editing ? '保存' : '创建'} + cancelText="取消" + width={800} + destroyOnClose + > + + + + + + + + + + + + + + + + + + handleFilterChange('type', v)} /> +
`共 ${total} 个充电站`, + onChange: (page, pageSize) => setFilters(prev => ({ ...prev, page, page_size: pageSize })), + }} + /> + + { setShowModal(false); form.resetFields(); }} + onOk={() => form.submit()} + okText={editing ? '保存' : '创建'} + cancelText="取消" + width={640} + destroyOnClose + > + + + + + + ({ label: m.name, value: m.id }))} /> + + + + + + + + + + + + + + + + + + + + + + + + + {Object.keys(chartData).length > 0 && ( + <> + + + + + +
`共 ${total} 条` }} + /> + + + )} + + + ); +} diff --git a/frontend/src/pages/Devices/Topology.tsx b/frontend/src/pages/Devices/Topology.tsx new file mode 100644 index 0000000..4d2fa55 --- /dev/null +++ b/frontend/src/pages/Devices/Topology.tsx @@ -0,0 +1,186 @@ +import { useEffect, useState } from 'react'; +import { Card, Tree, Table, Tag, Badge, Button, Space, Row, Col, Empty } from 'antd'; +import { ApartmentOutlined, ExpandOutlined, CompressOutlined } from '@ant-design/icons'; +import { getDeviceTopology, getDevices } from '../../services/api'; +import type { DataNode } from 'antd/es/tree'; + +const statusMap: Record = { + online: { color: 'green', text: '在线' }, + offline: { color: 'default', text: '离线' }, + alarm: { color: 'red', text: '告警' }, + maintenance: { color: 'orange', text: '维护' }, +}; + +function getStatusDot(node: any): string { + if (node.total_alarm > 0) return '#f5222d'; + if (node.total_offline > 0 && node.total_online > 0) return '#faad14'; + if (node.total_online > 0) return '#52c41a'; + if (node.total_device_count === 0) return '#d9d9d9'; + return '#999'; +} + +function buildTreeNodes(nodes: any[]): DataNode[] { + return nodes.map((node: any) => { + const dotColor = getStatusDot(node); + const title = ( + + + {node.name} + {node.location ? ({node.location}) : null} + + + ); + return { + title, + key: `group-${node.id}`, + children: buildTreeNodes(node.children || []), + isLeaf: !node.children || node.children.length === 0, + }; + }); +} + +export default function Topology() { + const [treeData, setTreeData] = useState([]); + const [topologyData, setTopologyData] = useState([]); + const [selectedGroupId, setSelectedGroupId] = useState(null); + const [selectedGroupName, setSelectedGroupName] = useState(''); + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(false); + const [expandedKeys, setExpandedKeys] = useState([]); + + useEffect(() => { + loadTopology(); + }, []); + + const loadTopology = async () => { + try { + const data = await getDeviceTopology() as any[]; + setTopologyData(data); + setTreeData(buildTreeNodes(data)); + // Expand all by default + const allKeys = collectKeys(data); + setExpandedKeys(allKeys); + } catch (e) { + console.error(e); + } + }; + + const collectKeys = (nodes: any[]): string[] => { + const keys: string[] = []; + nodes.forEach(n => { + keys.push(`group-${n.id}`); + if (n.children) { + keys.push(...collectKeys(n.children)); + } + }); + return keys; + }; + + const findGroupName = (nodes: any[], id: number): string => { + for (const n of nodes) { + if (n.id === id) return n.name; + if (n.children) { + const found = findGroupName(n.children, id); + if (found) return found; + } + } + return ''; + }; + + const handleSelect = async (selectedKeys: React.Key[]) => { + if (selectedKeys.length === 0) return; + const key = selectedKeys[0] as string; + const groupId = parseInt(key.replace('group-', '')); + setSelectedGroupId(groupId); + setSelectedGroupName(findGroupName(topologyData, groupId)); + setLoading(true); + try { + const res = await getDevices({ group_id: groupId, page_size: 100 }) as any; + setDevices(res.items || []); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + const handleExpandAll = () => { + setExpandedKeys(collectKeys(topologyData)); + }; + + const handleCollapseAll = () => { + setExpandedKeys([]); + }; + + const columns = [ + { title: '设备名称', dataIndex: 'name', width: 160 }, + { title: '设备编号', dataIndex: 'code', width: 130 }, + { title: '类型', dataIndex: 'device_type', width: 100 }, + { + title: '状态', dataIndex: 'status', width: 80, + render: (s: string) => { + const st = statusMap[s] || { color: 'default', text: s || '-' }; + return {st.text}; + }, + }, + { title: '位置', dataIndex: 'location', width: 120, ellipsis: true }, + { title: '额定功率(kW)', dataIndex: 'rated_power', width: 110, render: (v: number) => v != null ? v : '-' }, + { title: '最近数据时间', dataIndex: 'last_data_time', width: 170 }, + ]; + + return ( + + + 设备拓扑} + size="small" + extra={ + + + + + } + bodyStyle={{ maxHeight: 'calc(100vh - 200px)', overflow: 'auto' }} + > + setExpandedKeys(keys)} + onSelect={handleSelect} + showLine + blockNode + /> + + + + + + {selectedGroupId ? ( +
`共 ${total} 台` }} + /> + ) : ( + + )} + + + + ); +} diff --git a/frontend/src/pages/Maintenance/index.tsx b/frontend/src/pages/Maintenance/index.tsx new file mode 100644 index 0000000..369bca1 --- /dev/null +++ b/frontend/src/pages/Maintenance/index.tsx @@ -0,0 +1,399 @@ +import { useEffect, useState } from 'react'; +import { + Card, Table, Tag, Button, Tabs, Modal, Form, Input, Select, InputNumber, + Space, message, Row, Col, Statistic, DatePicker, Badge, +} from 'antd'; +import { + PlusOutlined, ToolOutlined, CheckOutlined, WarningOutlined, + ClockCircleOutlined, UserOutlined, +} from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { + getMaintenanceDashboard, getMaintenancePlans, createMaintenancePlan, + triggerMaintenancePlan, getMaintenanceRecords, getMaintenanceOrders, + createMaintenanceOrder, assignMaintenanceOrder, completeMaintenanceOrder, + getMaintenanceDuty, createMaintenanceDuty, +} from '../../services/api'; + +const priorityMap: Record = { + critical: { color: 'red', text: '紧急' }, + high: { color: 'orange', text: '高' }, + medium: { color: 'blue', text: '中' }, + low: { color: 'default', text: '低' }, +}; + +const orderStatusMap: Record = { + open: { color: 'red', text: '待处理' }, + assigned: { color: 'orange', text: '已指派' }, + in_progress: { color: 'processing', text: '处理中' }, + completed: { color: 'green', text: '已完成' }, + verified: { color: 'cyan', text: '已验证' }, + closed: { color: 'default', text: '已关闭' }, +}; + +const recordStatusMap: Record = { + pending: { color: 'default', text: '待执行' }, + in_progress: { color: 'processing', text: '执行中' }, + completed: { color: 'green', text: '已完成' }, + issues_found: { color: 'orange', text: '发现问题' }, +}; + +const shiftMap: Record = { + day: '白班', night: '夜班', on_call: '值班', +}; + +// ── Tab 1: Dashboard ─────────────────────────────────────────────── + +function DashboardTab() { + const [dashboard, setDashboard] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + (async () => { + try { + setDashboard(await getMaintenanceDashboard()); + } catch { message.error('加载运维概览失败'); } + finally { setLoading(false); } + })(); + }, []); + + const orderColumns = [ + { title: '工单号', dataIndex: 'code', width: 160 }, + { title: '标题', dataIndex: 'title' }, + { title: '优先级', dataIndex: 'priority', width: 80, render: (v: string) => { + const p = priorityMap[v] || { color: 'default', text: v }; + return {p.text}; + }}, + { title: '状态', dataIndex: 'status', width: 90, render: (v: string) => { + const s = orderStatusMap[v] || { color: 'default', text: v }; + return ; + }}, + { title: '创建时间', dataIndex: 'created_at', width: 170, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-' }, + ]; + + return ( +
+ +
+ } /> + + + } valueStyle={{ color: dashboard?.overdue_count > 0 ? '#f5222d' : undefined }} /> + + + } /> + + + } /> + + + +
+ + + ); +} + +// ── Tab 2: Inspection Plans ──────────────────────────────────────── + +function PlansTab() { + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + const [form] = Form.useForm(); + + const loadPlans = async () => { + setLoading(true); + try { setPlans(await getMaintenancePlans() as any[]); } + catch { message.error('加载巡检计划失败'); } + finally { setLoading(false); } + }; + + useEffect(() => { loadPlans(); }, []); + + const handleCreate = async (values: any) => { + try { + await createMaintenancePlan(values); + message.success('巡检计划创建成功'); + setShowModal(false); + form.resetFields(); + loadPlans(); + } catch { message.error('创建失败'); } + }; + + const handleTrigger = async (id: number) => { + try { + await triggerMaintenancePlan(id); + message.success('已触发巡检'); + } catch { message.error('触发失败'); } + }; + + const columns = [ + { title: '计划名称', dataIndex: 'name' }, + { title: '巡检周期', dataIndex: 'schedule_type', render: (v: string) => { + const map: Record = { daily: '每日', weekly: '每周', monthly: '每月', custom: '自定义' }; + return map[v] || v || '-'; + }}, + { title: '状态', dataIndex: 'is_active', width: 80, render: (v: boolean) => {v ? '启用' : '停用'} }, + { title: '下次执行', dataIndex: 'next_run_at', width: 170, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-' }, + { title: '操作', key: 'action', width: 120, render: (_: any, r: any) => ( + + )}, + ]; + + return ( + } onClick={() => setShowModal(true)}>新建计划}> +
+ setShowModal(false)} onOk={() => form.submit()} okText="创建" cancelText="取消"> + + + + + + + + + + + + + + ); +} + +// ── Tab 3: Inspection Records ────────────────────────────────────── + +function RecordsTab() { + const [records, setRecords] = useState({ total: 0, items: [] }); + const [loading, setLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState(); + + const loadRecords = async () => { + setLoading(true); + try { + setRecords(await getMaintenanceRecords({ status: statusFilter })); + } catch { message.error('加载巡检记录失败'); } + finally { setLoading(false); } + }; + + useEffect(() => { loadRecords(); }, [statusFilter]); + + const columns = [ + { title: 'ID', dataIndex: 'id', width: 60 }, + { title: '计划ID', dataIndex: 'plan_id', width: 80 }, + { title: '巡检人', dataIndex: 'inspector_id', width: 80 }, + { title: '状态', dataIndex: 'status', width: 100, render: (v: string) => { + const s = recordStatusMap[v] || { color: 'default', text: v }; + return {s.text}; + }}, + { title: '开始时间', dataIndex: 'started_at', width: 170, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-' }, + { title: '完成时间', dataIndex: 'completed_at', width: 170, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-' }, + ]; + + return ( + ({ label: v.text, value: k }))} /> + }> +
+ + ); +} + +// ── Tab 4: Repair Orders ─────────────────────────────────────────── + +function OrdersTab() { + const [orders, setOrders] = useState({ total: 0, items: [] }); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + const [assignModal, setAssignModal] = useState<{ open: boolean; orderId: number | null }>({ open: false, orderId: null }); + const [form] = Form.useForm(); + + const loadOrders = async () => { + setLoading(true); + try { setOrders(await getMaintenanceOrders({})); } + catch { message.error('加载工单失败'); } + finally { setLoading(false); } + }; + + useEffect(() => { loadOrders(); }, []); + + const handleCreate = async (values: any) => { + try { + await createMaintenanceOrder(values); + message.success('工单创建成功'); + setShowModal(false); + form.resetFields(); + loadOrders(); + } catch { message.error('创建失败'); } + }; + + const handleAssign = async (userId: number) => { + if (!assignModal.orderId) return; + try { + await assignMaintenanceOrder(assignModal.orderId, userId); + message.success('已指派'); + setAssignModal({ open: false, orderId: null }); + loadOrders(); + } catch { message.error('指派失败'); } + }; + + const handleComplete = async (id: number) => { + try { + await completeMaintenanceOrder(id); + message.success('已完成'); + loadOrders(); + } catch { message.error('操作失败'); } + }; + + const columns = [ + { title: '工单号', dataIndex: 'code', width: 160 }, + { title: '标题', dataIndex: 'title' }, + { title: '优先级', dataIndex: 'priority', width: 80, render: (v: string) => { + const p = priorityMap[v] || { color: 'default', text: v }; + return {p.text}; + }}, + { title: '状态', dataIndex: 'status', width: 90, render: (v: string) => { + const s = orderStatusMap[v] || { color: 'default', text: v }; + return ; + }}, + { title: '创建时间', dataIndex: 'created_at', width: 170, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-' }, + { title: '操作', key: 'action', width: 200, render: (_: any, r: any) => ( + + {r.status === 'open' && } + {['assigned', 'in_progress'].includes(r.status) && } + + )}, + ]; + + return ( + } onClick={() => setShowModal(true)}>新建工单}> +
+ + setShowModal(false)} onOk={() => form.submit()} okText="创建" cancelText="取消"> +
+ + + + + + + + + + +
+ + setShowModal(false)} onOk={() => form.submit()} okText="创建" cancelText="取消"> + + + + + + + + + setFilters(prev => ({ ...prev, category: v, page: 1 }))} /> +
`共 ${t} 条`, + onChange: (p, ps) => setFilters(prev => ({ ...prev, page: p, page_size: ps })), + }} /> + { setShowModal(false); form.resetFields(); }} + onOk={() => form.submit()} destroyOnClose width={600}> + + + ({ label: s.label, value: s.value }))} /> + + +