feat: v2.0 — maintenance module, AI analysis, station power fix

- Add full 检修维护中心 (6.4): 3-type work orders (消缺/巡检/抄表),
  asset management, warehouse, work plans, billing settlement
- Add AI智能分析 tab with LLM-powered diagnostics (StepFun + ZhipuAI)
- Add AI模型配置 settings page (provider, temperature, prompts)
- Fix station power accuracy: use API station total (station_power)
  instead of inverter-level computation — eliminates timing gaps
- Add 7 new DB models, 4 new API routers, 5 new frontend pages
- Migrations: 009 (maintenance expansion) + 010 (AI analysis)
- Version bump: 1.6.1 → 2.0.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Du Wenbo
2026-04-12 21:16:03 +08:00
parent 7947a230c4
commit f0f13faf00
30 changed files with 3325 additions and 52 deletions

View File

@@ -0,0 +1,167 @@
"""Add maintenance expansion tables and order_type
Revision ID: 009_maintenance_expansion
Revises: 008_management
Create Date: 2026-04-11
"""
from alembic import op
import sqlalchemy as sa
revision = "009_maintenance_expansion"
down_revision = "008_management"
branch_labels = None
depends_on = None
def upgrade() -> None:
# --- Add order_type, station_name, due_date to repair_orders ---
op.add_column("repair_orders", sa.Column("order_type", sa.String(20), server_default="repair"))
op.add_column("repair_orders", sa.Column("station_name", sa.String(200)))
op.add_column("repair_orders", sa.Column("due_date", sa.DateTime(timezone=True)))
# --- asset_categories ---
op.create_table(
"asset_categories",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("parent_id", sa.Integer, sa.ForeignKey("asset_categories.id"), nullable=True),
sa.Column("description", sa.Text),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# --- assets ---
op.create_table(
"assets",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("asset_code", sa.String(50), unique=True),
sa.Column("category_id", sa.Integer, sa.ForeignKey("asset_categories.id")),
sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id"), nullable=True),
sa.Column("station_name", sa.String(200)),
sa.Column("manufacturer", sa.String(200)),
sa.Column("model_number", sa.String(100)),
sa.Column("serial_number", sa.String(100)),
sa.Column("purchase_date", sa.DateTime(timezone=True)),
sa.Column("warranty_expiry", sa.DateTime(timezone=True)),
sa.Column("purchase_price", sa.Float),
sa.Column("status", sa.String(20), server_default="active"),
sa.Column("location", sa.String(200)),
sa.Column("responsible_dept", sa.String(100)),
sa.Column("custodian", sa.String(100)),
sa.Column("supplier", sa.String(200)),
sa.Column("notes", sa.Text),
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()),
)
# --- asset_changes ---
op.create_table(
"asset_changes",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("asset_id", sa.Integer, sa.ForeignKey("assets.id"), nullable=False),
sa.Column("change_type", sa.String(20)),
sa.Column("change_date", sa.DateTime(timezone=True)),
sa.Column("description", sa.Text),
sa.Column("old_value", sa.JSON),
sa.Column("new_value", sa.JSON),
sa.Column("operator_id", sa.Integer, sa.ForeignKey("users.id")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# --- spare_parts ---
op.create_table(
"spare_parts",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("part_code", sa.String(50), unique=True),
sa.Column("category", sa.String(100)),
sa.Column("specification", sa.String(200)),
sa.Column("unit", sa.String(20)),
sa.Column("current_stock", sa.Integer, server_default="0"),
sa.Column("min_stock", sa.Integer, server_default="0"),
sa.Column("max_stock", sa.Integer),
sa.Column("warehouse_location", sa.String(100)),
sa.Column("unit_price", sa.Float),
sa.Column("supplier", sa.String(200)),
sa.Column("notes", sa.Text),
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()),
)
# --- warehouse_transactions ---
op.create_table(
"warehouse_transactions",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("spare_part_id", sa.Integer, sa.ForeignKey("spare_parts.id"), nullable=False),
sa.Column("transaction_type", sa.String(20)),
sa.Column("quantity", sa.Integer, nullable=False),
sa.Column("unit_price", sa.Float),
sa.Column("total_price", sa.Float),
sa.Column("transaction_date", sa.DateTime(timezone=True)),
sa.Column("work_order_id", sa.Integer, sa.ForeignKey("repair_orders.id"), nullable=True),
sa.Column("reason", sa.String(200)),
sa.Column("operator_id", sa.Integer, sa.ForeignKey("users.id")),
sa.Column("notes", sa.Text),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# --- maintenance_work_plans ---
op.create_table(
"maintenance_work_plans",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("plan_type", sa.String(20)),
sa.Column("station_name", sa.String(200)),
sa.Column("device_ids", sa.JSON),
sa.Column("cycle_period", sa.String(20)),
sa.Column("execution_days", sa.Integer),
sa.Column("effective_start", sa.DateTime(timezone=True)),
sa.Column("effective_end", sa.DateTime(timezone=True)),
sa.Column("description", sa.Text),
sa.Column("workflow_config", sa.JSON),
sa.Column("is_active", sa.Boolean, server_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()),
)
# --- billing_records ---
op.create_table(
"billing_records",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("station_name", sa.String(200)),
sa.Column("billing_type", sa.String(20)),
sa.Column("billing_period_start", sa.DateTime(timezone=True)),
sa.Column("billing_period_end", sa.DateTime(timezone=True)),
sa.Column("generation_kwh", sa.Float),
sa.Column("self_use_kwh", sa.Float),
sa.Column("grid_feed_kwh", sa.Float),
sa.Column("electricity_price", sa.Float),
sa.Column("self_use_price", sa.Float),
sa.Column("feed_in_tariff", sa.Float),
sa.Column("total_amount", sa.Float),
sa.Column("self_use_amount", sa.Float),
sa.Column("feed_in_amount", sa.Float),
sa.Column("subsidy_amount", sa.Float),
sa.Column("status", sa.String(20), server_default="pending"),
sa.Column("invoice_number", sa.String(100)),
sa.Column("invoice_date", sa.DateTime(timezone=True)),
sa.Column("attachment_url", sa.String(500)),
sa.Column("notes", sa.Text),
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()),
)
def downgrade() -> None:
op.drop_table("billing_records")
op.drop_table("maintenance_work_plans")
op.drop_table("warehouse_transactions")
op.drop_table("spare_parts")
op.drop_table("asset_changes")
op.drop_table("assets")
op.drop_table("asset_categories")
op.drop_column("repair_orders", "due_date")
op.drop_column("repair_orders", "station_name")
op.drop_column("repair_orders", "order_type")

View File

@@ -0,0 +1,35 @@
"""Add AI analysis results table
Revision ID: 010_ai_analysis
Revises: 009_maintenance_expansion
Create Date: 2026-04-11
"""
from alembic import op
import sqlalchemy as sa
revision = "010_ai_analysis"
down_revision = "009_maintenance_expansion"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"ai_analysis_results",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("scope", sa.String(20), server_default="station"),
sa.Column("device_id", sa.Integer, sa.ForeignKey("devices.id"), nullable=True),
sa.Column("analysis_type", sa.String(20), server_default="diagnostic"),
sa.Column("prompt_used", sa.Text),
sa.Column("result_text", sa.Text),
sa.Column("model_used", sa.String(100)),
sa.Column("provider_used", sa.String(20)),
sa.Column("tokens_used", sa.Integer),
sa.Column("duration_ms", sa.Integer),
sa.Column("created_by", sa.Integer, sa.ForeignKey("users.id")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("ai_analysis_results")