ForgeFlow-ERP/backend/app/services/system_initializer.py

1042 lines
34 KiB
Python

from __future__ import annotations
import json
import importlib
import pkgutil
import shutil
import subprocess
import tempfile
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Iterable
import app.models # noqa: F401
from sqlalchemy import create_engine, func, inspect, select, text
from sqlalchemy.engine import Engine, URL
from sqlalchemy.orm import Session
import app.models as models_package
from app.models.base import Base
from app.models.master_data import ItemCategory, Unit, Warehouse
from app.models.operations import WarehouseLocation
from app.models.org import AiAssistantConfig, Department, Employee, Permission, Role, RolePermission, SystemConfig, User, UserRole
from app.services.auth import hash_password
from app.services.system_permissions import MENU_PERMISSION_TREE
BUSINESS_RESET_TABLES = [
"pp_scrap_record",
"po_receipt_item",
"po_purchase_return_item",
"rt_return_disposition",
"pp_work_order_material_issue",
"pp_operation_report",
"pp_material_issue_item",
"pp_completion_receipt_item",
"po_purchase_order_item",
"wh_stocktake_adjustment",
"wh_special_adjustment_line",
"rt_return_item",
"pp_work_order_operation",
"pp_work_order_material",
"pp_production_batch_ledger_txn",
"pp_material_issue",
"pp_completion_receipt",
"mrp_material_demand",
"fi_cost_allocation",
"em_maintenance_order",
"wh_stocktake_line",
"wh_inventory_txn",
"so_delivery_item",
"rt_return_order",
"pp_work_order",
"pp_production_batch_ledger",
"md_process_route_operation",
"md_bom_item",
"em_maintenance_plan",
"wh_stock_lot",
"wh_stock_balance",
"wh_special_adjustment",
"sys_broadcast_message",
"so_sales_order_item",
"so_delivery",
"po_receipt",
"po_purchase_return",
"po_purchase_order_sales_order",
"md_product",
"md_process_route",
"md_material",
"md_bom",
"fi_statement_snapshot",
"fi_overhead_entry",
"em_equipment",
"wh_stocktake_warehouse",
"so_sales_order",
"po_purchase_order",
"md_work_center",
"md_item",
"wh_stocktake",
"md_supplier",
"md_process",
"md_customer",
"fi_accounting_period",
"document_archives",
]
MINIAPP_RESET_TABLES = [
"report_audit_logs",
"production_report_items",
"mold_lock_feedbacks",
"work_session_devices",
"production_reports",
"notice_points",
"work_sessions",
"person_roles",
"person_attendance_points",
"notices",
"device_qrcodes",
"device_qrcode_batch_tasks",
"work_schedules",
"reconciliation_ledger_entries",
"products",
"personnel",
"equipment",
"attendance_points",
]
ACCOUNT_RESET_TABLES = [
"sys_user_role",
"sys_user",
"sys_org_manager_binding",
"sys_org_employee_binding",
"sys_department",
"hr_employee",
]
SKELETON_TABLES = [
"sys_ai_assistant_config",
"wh_location",
"sys_role_permission",
"wh_warehouse",
"sys_system_config",
"sys_role",
"sys_permission",
"md_unit",
"md_item_category",
]
PRE_DELETE_NULLABLE_FKS = {
"hr_employee": ["manager_employee_id"],
"sys_department": ["manager_employee_id"],
}
RESET_TABLES_IN_DELETE_ORDER = [
"pp_scrap_record",
"po_receipt_item",
"po_purchase_return_item",
"rt_return_disposition",
"pp_work_order_material_issue",
"pp_operation_report",
"pp_material_issue_item",
"pp_completion_receipt_item",
"po_purchase_order_item",
"wh_stocktake_adjustment",
"wh_special_adjustment_line",
"rt_return_item",
"pp_work_order_operation",
"pp_work_order_material",
"pp_production_batch_ledger_txn",
"pp_material_issue",
"pp_completion_receipt",
"mrp_material_demand",
"fi_cost_allocation",
"em_maintenance_order",
"wh_stocktake_line",
"wh_inventory_txn",
"so_delivery_item",
"rt_return_order",
"report_audit_logs",
"production_report_items",
"pp_work_order",
"pp_production_batch_ledger",
"mold_lock_feedbacks",
"md_process_route_operation",
"md_bom_item",
"em_maintenance_plan",
"work_session_devices",
"wh_stock_lot",
"wh_stock_balance",
"wh_special_adjustment",
"sys_user_role",
"sys_broadcast_message",
"sys_ai_assistant_config",
"so_sales_order_item",
"so_delivery",
"production_reports",
"po_receipt",
"po_purchase_return",
"po_purchase_order_sales_order",
"notice_points",
"md_product",
"md_process_route",
"md_material",
"md_bom",
"fi_statement_snapshot",
"fi_overhead_entry",
"em_equipment",
"work_sessions",
"wh_stocktake_warehouse",
"wh_location",
"sys_user",
"sys_role_permission",
"sys_org_manager_binding",
"sys_org_employee_binding",
"so_sales_order",
"po_purchase_order",
"person_roles",
"person_attendance_points",
"notices",
"md_work_center",
"md_item",
"device_qrcodes",
"device_qrcode_batch_tasks",
"work_schedules",
"wh_warehouse",
"wh_stocktake",
"sys_system_config",
"sys_role",
"sys_permission",
"reconciliation_ledger_entries",
"products",
"personnel",
"md_unit",
"md_supplier",
"md_process",
"md_item_category",
"md_customer",
"hr_employee",
"sys_department",
"fi_accounting_period",
"equipment",
"document_archives",
"attendance_points",
]
WAREHOUSE_TYPES = [
("RAW", "原材料库", "原材料默认库位"),
("SEMI", "半成品库", "半成品默认库位"),
("FINISHED", "成品库", "成品默认库位"),
("AUX", "辅料库", "辅料默认库位"),
("SCRAP", "废料库", "废料默认库位"),
("RETURN", "退货库", "退货默认库位"),
]
@dataclass(frozen=True)
class SystemInitializeOptions:
mode: str
company_name: str
admin_name: str
admin_phone: str
admin_password: str
smart_operation_report_enabled: bool = True
confirm_reset: bool = False
delete_files: bool = False
allow_any_database: bool = False
@dataclass(frozen=True)
class SystemInitializeResult:
mode: str
database: str = ""
backup_path: str | None = None
summary_path: str | None = None
counts_before: dict[str, int] = field(default_factory=dict)
counts_after: dict[str, int] = field(default_factory=dict)
seeded: dict[str, int | str | bool] = field(default_factory=dict)
def _now() -> datetime:
return datetime.now().replace(microsecond=0)
def _flatten_menu_permissions(nodes: list[tuple[str, str, list]]) -> list[tuple[str, str]]:
result: list[tuple[str, str]] = []
for code, name, children in nodes:
result.append((code, name))
result.extend(_flatten_menu_permissions(children))
return result
def _is_sqlite(db: Session) -> bool:
bind = db.get_bind()
return bool(bind and bind.dialect.name == "sqlite")
def _is_mysql(db: Session) -> bool:
bind = db.get_bind()
return bool(bind and bind.dialect.name in {"mysql", "mariadb"})
def _quote_table_name(table_name: str) -> str:
if not table_name.replace("_", "").isalnum():
raise ValueError(f"Unsafe table name: {table_name}")
return f"`{table_name}`"
def _existing_tables(db: Session, table_names: Iterable[str]) -> set[str]:
connection = db.connection()
if connection is None:
return set()
existing = set(inspect(connection).get_table_names())
return {table_name for table_name in table_names if table_name in existing}
def _count_table(db: Session, table_name: str) -> int:
return int(db.execute(text(f"SELECT COUNT(*) FROM {_quote_table_name(table_name)}")).scalar_one() or 0)
def count_existing_tables(db: Session, table_names: Iterable[str]) -> dict[str, int]:
db.flush()
existing = _existing_tables(db, table_names)
return {table_name: _count_table(db, table_name) for table_name in table_names if table_name in existing}
def _sqlite_has_sequence_table(db: Session) -> bool:
return bool(
db.execute(
text(
"""
SELECT name
FROM sqlite_master
WHERE type = 'table' AND name = 'sqlite_sequence'
"""
)
).first()
)
def _reset_sqlite_sequences(db: Session, table_names: Iterable[str]) -> None:
if not _sqlite_has_sequence_table(db):
return
for table_name in table_names:
db.execute(text("DELETE FROM sqlite_sequence WHERE name = :table_name"), {"table_name": table_name})
def _nullify_pre_delete_foreign_keys(db: Session, table_names: Iterable[str], existing_tables: set[str]) -> None:
for table_name in table_names:
columns = PRE_DELETE_NULLABLE_FKS.get(table_name, [])
if not columns or table_name not in existing_tables:
continue
assignments = ", ".join(f"{column_name} = NULL" for column_name in columns)
db.execute(text(f"UPDATE {_quote_table_name(table_name)} SET {assignments}"))
def _clear_tables(db: Session, table_names: Iterable[str]) -> None:
db.flush()
existing = _existing_tables(db, table_names)
tables_to_clear = [table_name for table_name in table_names if table_name in existing]
if not tables_to_clear:
return
if _is_mysql(db):
db.execute(text("SET FOREIGN_KEY_CHECKS = 0"))
try:
_nullify_pre_delete_foreign_keys(db, tables_to_clear, existing)
for table_name in tables_to_clear:
db.execute(text(f"DELETE FROM {_quote_table_name(table_name)}"))
if _is_sqlite(db):
_reset_sqlite_sequences(db, tables_to_clear)
finally:
if _is_mysql(db):
db.execute(text("SET FOREIGN_KEY_CHECKS = 1"))
def _summary_table_names() -> list[str]:
return list(RESET_TABLES_IN_DELETE_ORDER)
def _add_seed_row(db: Session, row: object) -> object:
"""SQLite does not autoincrement BIGINT PKs; MySQL still uses native AUTO_INCREMENT."""
if _is_sqlite(db) and getattr(row, "id", None) is None and hasattr(row, "__table__"):
primary_keys = list(row.__table__.primary_key.columns)
if len(primary_keys) == 1 and primary_keys[0].name == "id":
database_max = int(db.execute(select(func.coalesce(func.max(primary_keys[0]), 0)).select_from(row.__table__)).scalar_one() or 0)
pending_max = max(
(
int(pending_id)
for pending in db.new
if getattr(pending, "__table__", None) is row.__table__
and (pending_id := getattr(pending, "id", None)) is not None
),
default=0,
)
setattr(row, "id", max(database_max, pending_max) + 1)
db.add(row)
return row
def _upsert_system_config(db: Session, *, code: str, name: str, value: str, remark: str) -> None:
now = _now()
row = db.scalar(select(SystemConfig).where(SystemConfig.config_code == code))
if row is None:
_add_seed_row(
db,
SystemConfig(
config_code=code,
config_name=name,
config_value=value,
remark=remark,
status="ACTIVE",
created_by=None,
updated_by=None,
created_at=now,
updated_at=now,
),
)
return
row.config_name = name
row.config_value = value
row.remark = remark
row.status = "ACTIVE"
row.updated_at = now
def _upsert_role(db: Session, role_code: str, role_name: str) -> Role:
now = _now()
row = db.scalar(select(Role).where(Role.role_code == role_code))
if row is None:
row = Role(
role_code=role_code,
role_name=role_name,
role_scope="SYSTEM",
status="ACTIVE",
remark="系统初始化内置角色",
created_at=now,
updated_at=now,
)
_add_seed_row(db, row)
db.flush()
return row
row.role_name = role_name
row.role_scope = "SYSTEM"
row.status = "ACTIVE"
row.remark = "系统初始化内置角色"
row.updated_at = now
db.flush()
return row
def _seed_permissions_and_roles(db: Session) -> dict[str, int]:
now = _now()
role_defs = [
("ADMIN", "超级管理员"),
("PURCHASER", "采购专员"),
("WAREHOUSE", "仓库管理人员"),
("SALES", "销售人员"),
]
roles = [_upsert_role(db, code, name) for code, name in role_defs]
permissions: list[Permission] = []
for code, name in _flatten_menu_permissions(MENU_PERMISSION_TREE):
permission = db.scalar(select(Permission).where(Permission.permission_code == code))
if permission is None:
permission = Permission(
permission_code=code,
permission_name=name,
module_code=code.replace("MENU_", ""),
action_code="VIEW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
_add_seed_row(db, permission)
db.flush()
else:
permission.permission_name = name
permission.module_code = code.replace("MENU_", "")
permission.action_code = "VIEW"
permission.status = "ACTIVE"
permission.updated_at = now
db.flush()
permissions.append(permission)
admin_role = roles[0]
for permission in permissions:
existing = db.scalar(
select(RolePermission).where(
RolePermission.role_id == admin_role.id,
RolePermission.permission_id == permission.id,
)
)
if existing is None:
_add_seed_row(db, RolePermission(role_id=admin_role.id, permission_id=permission.id, created_at=now))
return {"role_count": len(role_defs), "permission_count": len(permissions)}
def _seed_system_configs(db: Session, options: SystemInitializeOptions) -> None:
_upsert_system_config(
db,
code="RAW_MATERIAL_LOT_PREFIX",
name="原材料库存批次前缀",
value="YL",
remark="系统初始化默认值",
)
_upsert_system_config(
db,
code="SMART_OPERATION_REPORT_ENABLED",
name="对接智能报工小程序",
value="开启" if options.smart_operation_report_enabled else "关闭",
remark="系统初始化默认值",
)
def _seed_units_and_categories(db: Session) -> dict[str, int]:
now = _now()
unit_defs = [
("KG", "kg", 3),
("PCS", "", 0),
("SET", "", 0),
]
for code, name, precision in unit_defs:
unit = db.scalar(select(Unit).where(Unit.unit_code == code))
if unit is None:
_add_seed_row(db, Unit(unit_code=code, unit_name=name, precision_digits=precision, created_at=now, updated_at=now))
else:
unit.unit_name = name
unit.precision_digits = precision
unit.updated_at = now
category_defs = [
("RAW", "原材料", "RAW_MATERIAL"),
("SEMI", "半成品", "SEMI_FINISHED"),
("FINISHED", "成品", "FINISHED_GOOD"),
("AUX", "辅料", "AUXILIARY"),
("SCRAP", "废料", "SCRAP"),
]
for code, name, item_type in category_defs:
category = db.scalar(select(ItemCategory).where(ItemCategory.category_code == code))
if category is None:
_add_seed_row(
db,
ItemCategory(
category_code=code,
category_name=name,
parent_id=None,
item_type=item_type,
sort_no=0,
status="ACTIVE",
created_at=now,
updated_at=now,
),
)
else:
category.category_name = name
category.item_type = item_type
category.status = "ACTIVE"
category.updated_at = now
return {"unit_count": len(unit_defs), "category_count": len(category_defs)}
def _seed_warehouses(db: Session) -> dict[str, int]:
now = _now()
for warehouse_type, warehouse_name, location_name in WAREHOUSE_TYPES:
warehouse = db.scalar(select(Warehouse).where(Warehouse.warehouse_type == warehouse_type))
if warehouse is None:
warehouse = Warehouse(
warehouse_code=f"WH_{warehouse_type}",
warehouse_name=warehouse_name,
warehouse_type=warehouse_type,
manager_employee_id=None,
status="ACTIVE",
remark="系统初始化默认仓库",
created_at=now,
updated_at=now,
)
_add_seed_row(db, warehouse)
db.flush()
else:
warehouse.warehouse_code = f"WH_{warehouse_type}"
warehouse.warehouse_name = warehouse_name
warehouse.status = "ACTIVE"
warehouse.updated_at = now
db.flush()
location = db.scalar(
select(WarehouseLocation).where(
WarehouseLocation.warehouse_id == warehouse.id,
WarehouseLocation.location_code == f"LOC_{warehouse_type}",
)
)
if location is None:
_add_seed_row(
db,
WarehouseLocation(
warehouse_id=warehouse.id,
location_code=f"LOC_{warehouse_type}",
location_name=location_name,
zone_name=None,
is_default=1,
is_locked=0,
status="ACTIVE",
remark="系统初始化默认库位",
created_at=now,
updated_at=now,
),
)
else:
location.location_name = location_name
location.is_default = 1
location.is_locked = 0
location.status = "ACTIVE"
location.updated_at = now
return {"warehouse_count": len(WAREHOUSE_TYPES), "location_count": len(WAREHOUSE_TYPES)}
def _seed_admin_user(db: Session, options: SystemInitializeOptions) -> dict[str, int | str]:
now = _now()
root = db.scalar(select(Department).where(Department.dept_code == "ORG_ROOT"))
if root is None:
root = Department(
dept_code="ORG_ROOT",
dept_name=options.company_name,
parent_id=None,
org_node_type="COMPANY",
dept_type="ADMIN",
manager_name=None,
manager_employee_id=None,
status="ACTIVE",
sort_no=0,
remark="系统初始化根组织",
created_at=now,
updated_at=now,
)
_add_seed_row(db, root)
db.flush()
else:
root.dept_name = options.company_name
root.org_node_type = "COMPANY"
root.dept_type = "ADMIN"
root.status = "ACTIVE"
root.updated_at = now
db.flush()
employee = db.scalar(select(Employee).where(Employee.employee_code == "EMP_ADMIN"))
if employee is None:
employee = Employee(
employee_code="EMP_ADMIN",
employee_name=options.admin_name,
dept_id=root.id,
mobile=options.admin_phone,
gender=None,
hire_date=None,
job_title="超级管理员",
shift_code=None,
manager_employee_id=None,
is_operator=0,
is_workshop_staff=0,
status="ACTIVE",
remark="系统初始化超级管理员",
created_at=now,
updated_at=now,
)
_add_seed_row(db, employee)
db.flush()
else:
employee.employee_name = options.admin_name
employee.dept_id = root.id
employee.mobile = options.admin_phone
employee.status = "ACTIVE"
employee.updated_at = now
db.flush()
user = db.scalar(select(User).where(User.username == options.admin_phone))
if user is None:
user = User(
username=options.admin_phone,
password_hash=hash_password(options.admin_password),
employee_id=employee.id,
dept_id=root.id,
nickname=options.admin_name,
email=None,
is_super_admin=1,
last_login_at=None,
status="ACTIVE",
created_at=now,
updated_at=now,
)
_add_seed_row(db, user)
db.flush()
else:
user.password_hash = hash_password(options.admin_password)
user.employee_id = employee.id
user.dept_id = root.id
user.nickname = options.admin_name
user.is_super_admin = 1
user.status = "ACTIVE"
user.updated_at = now
db.flush()
admin_role = db.scalar(select(Role).where(Role.role_code == "ADMIN"))
if admin_role is not None:
existing = db.scalar(select(UserRole).where(UserRole.user_id == user.id, UserRole.role_id == admin_role.id))
if existing is None:
_add_seed_row(db, UserRole(user_id=user.id, role_id=admin_role.id, created_at=now))
return {"admin_user_id": user.id, "admin_username": user.username}
def _seed_ai_assistant_config(db: Session, options: SystemInitializeOptions, admin_user_id: int | None) -> dict[str, int | str]:
now = _now()
row = db.scalar(select(AiAssistantConfig).order_by(AiAssistantConfig.id.asc()))
assistant_name = f"{options.company_name}工艺助手"
if row is None:
row = AiAssistantConfig(
assistant_name=assistant_name,
provider_name=None,
api_base_url=None,
api_key=None,
model_name=None,
temperature=0.3,
system_prompt=None,
enabled=0,
created_by=admin_user_id,
updated_by=admin_user_id,
created_at=now,
updated_at=now,
)
_add_seed_row(db, row)
db.flush()
else:
row.assistant_name = assistant_name
row.updated_by = admin_user_id
row.updated_at = now
db.flush()
return {"ai_assistant_config_count": 1, "ai_assistant_name": assistant_name}
def _seed_miniapp_defaults(db: Session, options: SystemInitializeOptions) -> dict[str, int]:
now = _now()
point = db.execute(text("SELECT name FROM attendance_points WHERE name = :name"), {"name": options.company_name}).first()
if point is None:
db.execute(
text(
"""
INSERT INTO attendance_points
(name, latitude, longitude, radius_meters, remark, is_active, created_at, updated_at)
VALUES
(:name, NULL, NULL, 500, '系统初始化默认考勤点', 1, :now, :now)
"""
),
{"name": options.company_name, "now": now},
)
else:
db.execute(
text(
"""
UPDATE attendance_points
SET remark = '系统初始化默认考勤点', is_active = 1, updated_at = :now
WHERE name = :name
"""
),
{"name": options.company_name, "now": now},
)
person = db.execute(text("SELECT phone FROM personnel WHERE phone = :phone"), {"phone": options.admin_phone}).first()
if person is None:
db.execute(
text(
"""
INSERT INTO personnel (phone, name, is_temporary, temporary_expires_at, created_at, updated_at)
VALUES (:phone, :name, 0, NULL, :now, :now)
"""
),
{"phone": options.admin_phone, "name": options.admin_name, "now": now},
)
else:
db.execute(
text(
"""
UPDATE personnel
SET name = :name, is_temporary = 0, temporary_expires_at = NULL, updated_at = :now
WHERE phone = :phone
"""
),
{"phone": options.admin_phone, "name": options.admin_name, "now": now},
)
role = db.execute(
text("SELECT phone FROM person_roles WHERE phone = :phone AND role = 'admin'"),
{"phone": options.admin_phone},
).first()
if role is None:
db.execute(
text("INSERT INTO person_roles (phone, role, created_at) VALUES (:phone, 'admin', :now)"),
{"phone": options.admin_phone, "now": now},
)
binding = db.execute(
text(
"""
SELECT phone
FROM person_attendance_points
WHERE phone = :phone AND attendance_point_name = :point
"""
),
{"phone": options.admin_phone, "point": options.company_name},
).first()
if binding is None:
db.execute(
text(
"""
INSERT INTO person_attendance_points (phone, attendance_point_name, created_at)
VALUES (:phone, :point, :now)
"""
),
{"phone": options.admin_phone, "point": options.company_name, "now": now},
)
return {"miniapp_attendance_point_count": 1, "miniapp_admin_count": 1}
def seed_system_skeleton(db: Session, options: SystemInitializeOptions) -> SystemInitializeResult:
permission_summary = _seed_permissions_and_roles(db)
_seed_system_configs(db, options)
unit_summary = _seed_units_and_categories(db)
warehouse_summary = _seed_warehouses(db)
admin_summary = _seed_admin_user(db, options)
ai_summary = _seed_ai_assistant_config(db, options, int(admin_summary["admin_user_id"]) if admin_summary.get("admin_user_id") else None)
miniapp_summary = _seed_miniapp_defaults(db, options)
return SystemInitializeResult(
mode=options.mode,
seeded={
**permission_summary,
**unit_summary,
**warehouse_summary,
**admin_summary,
**ai_summary,
**miniapp_summary,
"smart_operation_report_enabled": options.smart_operation_report_enabled,
},
)
def reset_existing_database(db: Session, options: SystemInitializeOptions) -> SystemInitializeResult:
if not options.confirm_reset:
raise ValueError("Refusing to reset database without --confirm-reset")
table_names = _summary_table_names()
counts_before = count_existing_tables(db, table_names)
_clear_tables(db, table_names)
seeded_result = seed_system_skeleton(db, options)
db.flush()
counts_after = count_existing_tables(db, table_names)
return SystemInitializeResult(
mode="reset",
counts_before=counts_before,
counts_after=counts_after,
seeded=seeded_result.seeded,
)
def create_all_schema_tables(engine: Engine) -> None:
for module_info in pkgutil.iter_modules(models_package.__path__):
if module_info.name.startswith("_"):
continue
importlib.import_module(f"{models_package.__name__}.{module_info.name}")
Base.metadata.create_all(engine)
def fresh_initialize_database(engine: Engine, options: SystemInitializeOptions) -> SystemInitializeResult:
create_all_schema_tables(engine)
fresh_options = SystemInitializeOptions(
mode="fresh",
company_name=options.company_name,
admin_name=options.admin_name,
admin_phone=options.admin_phone,
admin_password=options.admin_password,
smart_operation_report_enabled=options.smart_operation_report_enabled,
confirm_reset=options.confirm_reset,
delete_files=options.delete_files,
allow_any_database=options.allow_any_database,
)
with Session(engine, future=True) as db:
seeded_result = seed_system_skeleton(db, fresh_options)
db.flush()
counts_after = count_existing_tables(db, _summary_table_names())
db.commit()
return SystemInitializeResult(
mode="fresh",
counts_after=counts_after,
seeded=seeded_result.seeded,
)
def _mysql_url_from_settings(settings: object, *, include_database: bool) -> URL:
dsn = getattr(settings, "mysql_dsn", None)
if isinstance(dsn, URL):
return dsn.set(database=getattr(settings, "mysql_database", None) if include_database else None)
return URL.create(
"mysql+pymysql",
username=getattr(settings, "mysql_user"),
password=getattr(settings, "mysql_password"),
host=getattr(settings, "mysql_host"),
port=getattr(settings, "mysql_port"),
database=getattr(settings, "mysql_database") if include_database else None,
)
def create_database_if_missing(settings: object) -> str:
database = str(getattr(settings, "mysql_database"))
server_url = _mysql_url_from_settings(settings, include_database=False)
engine = create_engine(server_url, isolation_level="AUTOCOMMIT", future=True)
try:
with engine.connect() as conn:
conn.execute(
text(
f"CREATE DATABASE IF NOT EXISTS {_quote_table_name(database)} "
"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
)
finally:
engine.dispose()
return database
def write_summary_report(output_dir: str | Path, result: SystemInitializeResult) -> str:
output = Path(output_dir)
output.mkdir(parents=True, exist_ok=True)
created_at = _now().isoformat()
summary_path = output / f"system_init_summary_{created_at.replace(':', '').replace('-', '')}.json"
payload = {
"mode": result.mode,
"database": result.database,
"backup_path": result.backup_path,
"summary_path": str(summary_path),
"counts_before": result.counts_before,
"counts_after": result.counts_after,
"seeded": result.seeded,
"created_at": created_at,
}
summary_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
return str(summary_path)
def default_output_dir() -> Path:
return Path(__file__).resolve().parents[3] / "outputs" / "cleanup_backups"
def backup_mysql_database(settings: object, output_dir: str | Path) -> str:
output = Path(output_dir)
output.mkdir(parents=True, exist_ok=True)
timestamp = _now().strftime("%Y%m%d_%H%M%S")
backup_path = output / f"system_init_backup_{timestamp}.sql"
database = str(getattr(settings, "mysql_database"))
password = str(getattr(settings, "mysql_password", "") or "")
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as defaults_file:
defaults_path = Path(defaults_file.name)
defaults_file.write("[client]\n")
defaults_file.write(f"host={getattr(settings, 'mysql_host')}\n")
defaults_file.write(f"port={getattr(settings, 'mysql_port')}\n")
defaults_file.write(f"user={getattr(settings, 'mysql_user')}\n")
if password:
defaults_file.write(f"password={password}\n")
defaults_path.chmod(0o600)
command = [
"mysqldump",
f"--defaults-extra-file={defaults_path}",
f"--result-file={backup_path}",
"--single-transaction",
"--no-tablespaces",
"--routines",
"--triggers",
database,
]
try:
subprocess.run(command, check=True)
finally:
defaults_path.unlink(missing_ok=True)
return str(backup_path)
def delete_managed_files(directories: Iterable[Path]) -> dict[str, int]:
def count_tree(path: Path) -> tuple[int, int]:
if path.is_file() or path.is_symlink():
return 1, 0
file_count = 0
dir_count = 1
for nested in path.iterdir():
nested_file_count, nested_dir_count = count_tree(nested)
file_count += nested_file_count
dir_count += nested_dir_count
return file_count, dir_count
deleted_file_count = 0
deleted_dir_count = 0
for directory in directories:
if not directory.exists() or not directory.is_dir():
continue
for child in directory.iterdir():
if child.is_file() or child.is_symlink():
child.unlink()
deleted_file_count += 1
elif child.is_dir():
nested_file_count, nested_dir_count = count_tree(child)
shutil.rmtree(child)
deleted_file_count += nested_file_count
deleted_dir_count += nested_dir_count
return {"deleted_file_count": deleted_file_count, "deleted_dir_count": deleted_dir_count}
def reset_existing_database_with_backup(
engine: Engine,
settings: object,
options: SystemInitializeOptions,
output_dir: str | Path | None = None,
) -> SystemInitializeResult:
output = Path(output_dir) if output_dir is not None else default_output_dir()
backup_path = backup_mysql_database(settings, output)
database = str(getattr(settings, "mysql_database", ""))
with Session(engine, future=True) as db:
reset_result = reset_existing_database(db, options)
db.commit()
result = SystemInitializeResult(
mode=reset_result.mode,
database=database,
backup_path=backup_path,
counts_before=reset_result.counts_before,
counts_after=reset_result.counts_after,
seeded=reset_result.seeded,
)
if options.delete_files:
root = Path(__file__).resolve().parents[3]
file_cleanup = delete_managed_files(
[
root / "outputs" / "document_archives",
root / "outputs" / "document_archive_batches",
root / "backend" / "uploads" / "logistics",
]
)
result = SystemInitializeResult(
mode=result.mode,
database=result.database,
backup_path=result.backup_path,
counts_before=result.counts_before,
counts_after=result.counts_after,
seeded={**result.seeded, **file_cleanup},
)
summary_path = write_summary_report(output, result)
return SystemInitializeResult(
mode=result.mode,
database=result.database,
backup_path=result.backup_path,
summary_path=summary_path,
counts_before=result.counts_before,
counts_after=result.counts_after,
seeded=result.seeded,
)