1042 lines
34 KiB
Python
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,
|
|
)
|