71 KiB
System Initialization Bootstrap Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a standard initialization tool that can create a clean ERP + miniapp database for a new customer and reset an existing test database into a clean customer-ready system.
Architecture: Add a focused backend service layer for database initialization, driven by a thin CLI script. The service imports all SQLAlchemy models, creates all ERP and miniapp tables for fresh, performs protected backups and table resets for reset, and seeds only the minimal system skeleton required for login, permissions, system config, warehouses, locations, units, and categories.
Tech Stack: Python 3.12, SQLAlchemy 2, PyMySQL, Pydantic settings, existing FastAPI backend models/services, MySQL 8, unittest/pytest-style backend tests.
File Structure
- Create:
backend/scripts/system_initialize.py- CLI entry point. Parses
freshandreset, validates flags, calls the service, prints report paths.
- CLI entry point. Parses
- Create:
backend/app/services/system_initializer.py- Core initialization service. Owns model imports, table groups, backup/report helpers, schema creation, table reset, seed routines.
- Modify:
backend/app/models/miniapp.py- Add missing miniapp backend tables so ERP metadata can create the complete miniapp schema.
- Modify:
backend/app/models/__init__.py- Export/import complete model modules so
Base.metadatais complete in initializer and tests.
- Export/import complete model modules so
- Create:
backend/tests/test_system_initializer_models.py- Verifies all required ERP and miniapp table names are registered in
Base.metadata.
- Verifies all required ERP and miniapp table names are registered in
- Create:
backend/tests/test_system_initializer_seed.py- Verifies minimal system skeleton is seeded correctly in an in-memory database.
- Create:
backend/tests/test_system_initializer_reset.py- Verifies reset clears business data, clears people/accounts, preserves/rebuilds skeleton, and refuses destructive reset without confirmation.
- Modify:
docs/superpowers/specs/2026-06-14-system-initialization-bootstrap-design.md- Add notes if implementation discovers a schema gap that must be reflected in design.
Task 1: Register Complete Miniapp Table Coverage
Files:
-
Modify:
backend/app/models/miniapp.py -
Modify:
backend/app/models/__init__.py -
Test:
backend/tests/test_system_initializer_models.py -
Step 1: Write failing metadata coverage test
Create backend/tests/test_system_initializer_models.py:
from __future__ import annotations
import unittest
from sqlalchemy import BigInteger, create_engine
from sqlalchemy.ext.compiler import compiles
@compiles(BigInteger, "sqlite")
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
_ = type_, compiler, kw
return "INTEGER"
import app.models.document_archive # noqa: E402,F401
import app.models.master_data # noqa: E402,F401
import app.models.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.models.base import Base # noqa: E402
class SystemInitializerModelCoverageTest(unittest.TestCase):
def test_metadata_contains_required_erp_and_miniapp_tables(self) -> None:
required_tables = {
"sys_department",
"hr_employee",
"sys_role",
"sys_permission",
"sys_role_permission",
"sys_user",
"sys_user_role",
"sys_system_config",
"sys_ai_assistant_config",
"md_unit",
"md_item_category",
"md_item",
"md_material",
"md_product",
"md_bom",
"md_bom_item",
"wh_warehouse",
"wh_location",
"wh_stock_lot",
"wh_stock_balance",
"wh_inventory_txn",
"pp_production_batch_ledger",
"pp_production_batch_ledger_txn",
"document_archives",
"attendance_points",
"personnel",
"person_roles",
"person_attendance_points",
"products",
"equipment",
"work_schedules",
"work_sessions",
"work_session_devices",
"production_reports",
"production_report_items",
"report_audit_logs",
"mold_lock_feedbacks",
"device_qrcodes",
"device_qrcode_batch_tasks",
"notices",
"notice_points",
"reconciliation_ledger_entries",
}
self.assertTrue(required_tables.issubset(set(Base.metadata.tables)))
def test_metadata_can_create_all_tables_on_sqlite(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
created_tables = set(Base.metadata.tables)
self.assertIn("work_sessions", created_tables)
self.assertIn("production_report_items", created_tables)
self.assertIn("pp_production_batch_ledger", created_tables)
if __name__ == "__main__":
unittest.main()
- Step 2: Run test to verify current failure
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_initializer_models.py -q
Expected: FAIL because work_sessions, work_session_devices, report_audit_logs, equipment, work_schedules, mold_lock_feedbacks, device_qrcodes, device_qrcode_batch_tasks, and reconciliation_ledger_entries are not registered in ERP metadata.
- Step 3: Add missing miniapp model classes
Modify backend/app/models/miniapp.py.
Keep existing classes, then add missing classes aligned with /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/models.py. Also expand existing fields that the miniapp backend already uses.
Add imports:
from sqlalchemy import BigInteger, Boolean, Date, DateTime, DECIMAL, ForeignKey, Integer, String, Text, text
from sqlalchemy.dialects.mysql import JSON
from sqlalchemy.orm import Mapped, mapped_column
Update MiniAppAttendancePoint to include schedule columns:
day_start: Mapped[str] = mapped_column(String(5), server_default=text("'08:00'"))
day_end: Mapped[str] = mapped_column(String(5), server_default=text("'17:20'"))
lunch_start: Mapped[str] = mapped_column(String(5), server_default=text("'11:40'"))
lunch_end: Mapped[str] = mapped_column(String(5), server_default=text("'12:40'"))
dinner_start: Mapped[str] = mapped_column(String(5), server_default=text("'17:20'"))
dinner_end: Mapped[str] = mapped_column(String(5), server_default=text("'18:00'"))
overtime_start: Mapped[str] = mapped_column(String(5), server_default=text("'18:00'"))
overtime_end: Mapped[str] = mapped_column(String(5), server_default=text("'20:00'"))
night_start: Mapped[str] = mapped_column(String(5), server_default=text("'20:00'"))
night_end: Mapped[str] = mapped_column(String(5), server_default=text("'06:00'"))
Add missing classes:
class MiniAppEquipment(Base):
__tablename__ = "equipment"
attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, server_default=text("''"))
device_no: Mapped[str] = mapped_column(String(64), primary_key=True)
device_type: Mapped[str] = mapped_column(String(32), server_default=text("'冲压设备'"))
remark: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime)
class MiniAppWorkSchedule(Base):
__tablename__ = "work_schedules"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
day_start: Mapped[str] = mapped_column(String(5), server_default=text("'08:00'"))
day_end: Mapped[str] = mapped_column(String(5), server_default=text("'17:20'"))
lunch_start: Mapped[str] = mapped_column(String(5), server_default=text("'11:40'"))
lunch_end: Mapped[str] = mapped_column(String(5), server_default=text("'12:40'"))
dinner_start: Mapped[str] = mapped_column(String(5), server_default=text("'17:20'"))
dinner_end: Mapped[str] = mapped_column(String(5), server_default=text("'18:00'"))
overtime_start: Mapped[str] = mapped_column(String(5), server_default=text("'18:00'"))
overtime_end: Mapped[str] = mapped_column(String(5), server_default=text("'20:00'"))
night_start: Mapped[str] = mapped_column(String(5), server_default=text("'20:00'"))
night_end: Mapped[str] = mapped_column(String(5), server_default=text("'06:00'"))
attendance_latitude: Mapped[float | None] = mapped_column(DECIMAL(10, 7), nullable=True)
attendance_longitude: Mapped[float | None] = mapped_column(DECIMAL(10, 7), nullable=True)
attendance_radius_meters: Mapped[int] = mapped_column(Integer, server_default=text("500"))
auto_submit_hours: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("15"))
updated_by: Mapped[str | None] = mapped_column(String(20), nullable=True)
created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime)
class MiniAppWorkSession(Base):
__tablename__ = "work_sessions"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("''"))
employee_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
start_at: Mapped[object] = mapped_column(DateTime)
end_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
status: Mapped[str] = mapped_column(String(32), server_default=text("'active'"))
created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime)
class MiniAppWorkSessionDevice(Base):
__tablename__ = "work_session_devices"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(ForeignKey("work_sessions.id"))
attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("''"))
device_no: Mapped[str] = mapped_column(String(255))
process_name: Mapped[str] = mapped_column(String(128), server_default=text("''"))
scanned_at: Mapped[object] = mapped_column(DateTime)
sort_order: Mapped[int] = mapped_column(Integer, server_default=text("0"))
released_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
released_by: Mapped[str | None] = mapped_column(String(20), nullable=True)
release_reason: Mapped[str | None] = mapped_column(String(120), nullable=True)
class MiniAppReportAuditLog(Base):
__tablename__ = "report_audit_logs"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
report_id: Mapped[int] = mapped_column(ForeignKey("production_reports.id"))
reviewer_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
action: Mapped[str] = mapped_column(String(64))
before_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
after_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
remark: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[object] = mapped_column(DateTime)
class MiniAppMoldLockFeedback(Base):
__tablename__ = "mold_lock_feedbacks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("''"))
mold_name: Mapped[str] = mapped_column(String(255))
process_name: Mapped[str] = mapped_column(String(128), server_default=text("''"))
session_device_id: Mapped[int] = mapped_column(ForeignKey("work_session_devices.id"))
reporter_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
occupied_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
status: Mapped[str] = mapped_column(String(32), server_default=text("'pending'"))
read_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
handled_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
handled_by: Mapped[str | None] = mapped_column(ForeignKey("personnel.phone"), nullable=True)
created_at: Mapped[object] = mapped_column(DateTime)
class MiniAppDeviceQRCode(Base):
__tablename__ = "device_qrcodes"
attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, server_default=text("''"))
device_no: Mapped[str] = mapped_column(String(255), primary_key=True)
process_name: Mapped[str] = mapped_column(String(128), primary_key=True, server_default=text("''"))
qr_scene: Mapped[str] = mapped_column(String(128))
qr_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_by: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
created_at: Mapped[object] = mapped_column(DateTime)
class MiniAppDeviceQrBatchTask(Base):
__tablename__ = "device_qrcode_batch_tasks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
file_name: Mapped[str] = mapped_column(String(255))
status: Mapped[str] = mapped_column(String(32), server_default=text("'pending'"))
item_count: Mapped[int] = mapped_column(Integer, server_default=text("0"))
completed_count: Mapped[int] = mapped_column(Integer, server_default=text("0"))
failed_count: Mapped[int] = mapped_column(Integer, server_default=text("0"))
items_json: Mapped[list] = mapped_column(JSON)
zip_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
error_message: Mapped[str | None] = mapped_column(String(1000), nullable=True)
created_by: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
started_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
finished_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime)
class MiniAppReconciliationLedgerEntry(Base):
__tablename__ = "reconciliation_ledger_entries"
attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, server_default=text("''"))
year: Mapped[int] = mapped_column(Integer, primary_key=True)
month: Mapped[int] = mapped_column(Integer, primary_key=True)
product_name: Mapped[str] = mapped_column(String(255), primary_key=True)
reconciled_good_qty: Mapped[float] = mapped_column(DECIMAL(14, 2), server_default=text("0"))
return_qty: Mapped[float] = mapped_column(DECIMAL(14, 2), server_default=text("0"))
updated_by: Mapped[str | None] = mapped_column(String(20), nullable=True)
created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime)
Expand existing classes:
-
MiniAppProduct: addprocess_unit_price_yuan. -
MiniAppProductionReport: addreview_remark,is_system_auto_submitted,auto_submit_reason,is_multi_person_assistant,multi_person_source_report_id,is_voided,voided_at,voided_by,unvoid_deadline_at. -
MiniAppProductionReportItem: addoperator_count,process_unit_price_yuan,remark. -
Step 4: Update model package exports
Modify backend/app/models/__init__.py to import all model modules for side effects:
from app.models import document_archive as document_archive
from app.models import master_data as master_data
from app.models import miniapp as miniapp
from app.models import operations as operations
from app.models import org as org
from app.models import planning as planning
from app.models import sales as sales
Keep the existing class exports below those imports.
- Step 5: Run model coverage test
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_initializer_models.py -q
Expected: PASS.
- Step 6: Commit
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add backend/app/models/miniapp.py backend/app/models/__init__.py backend/tests/test_system_initializer_models.py
git commit -m "feat: register full miniapp schema for initialization"
Task 2: Add Initializer Service Constants And Options
Files:
-
Create:
backend/app/services/system_initializer.py -
Test:
backend/tests/test_system_initializer_seed.py -
Step 1: Write failing table group test
Create backend/tests/test_system_initializer_seed.py with the first test:
from __future__ import annotations
import unittest
class SystemInitializerSeedTest(unittest.TestCase):
def test_table_groups_have_no_duplicates_and_include_expected_tables(self) -> None:
from app.services.system_initializer import (
BUSINESS_RESET_TABLES,
MINIAPP_RESET_TABLES,
SKELETON_TABLES,
)
all_tables = BUSINESS_RESET_TABLES + MINIAPP_RESET_TABLES + SKELETON_TABLES
self.assertEqual(len(all_tables), len(set(all_tables)))
self.assertIn("so_sales_order", BUSINESS_RESET_TABLES)
self.assertIn("wh_stock_lot", BUSINESS_RESET_TABLES)
self.assertIn("pp_production_batch_ledger", BUSINESS_RESET_TABLES)
self.assertIn("production_reports", MINIAPP_RESET_TABLES)
self.assertIn("work_sessions", MINIAPP_RESET_TABLES)
self.assertIn("sys_permission", SKELETON_TABLES)
self.assertIn("wh_warehouse", SKELETON_TABLES)
if __name__ == "__main__":
unittest.main()
- Step 2: Run test to verify it fails
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_initializer_seed.py::SystemInitializerSeedTest::test_table_groups_have_no_duplicates_and_include_expected_tables -q
Expected: FAIL because app.services.system_initializer does not exist.
- Step 3: Create service constants and option dataclasses
Create backend/app/services/system_initializer.py:
from __future__ import annotations
import json
import shutil
import subprocess
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable
from sqlalchemy import create_engine, inspect, select, text
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from app.core.config import Settings, get_settings
from app.models.base import Base
from app.services.auth import hash_password
BUSINESS_RESET_TABLES = [
"document_archives",
"rt_return_disposition",
"rt_return_item",
"rt_return_order",
"so_delivery_item",
"so_delivery",
"pp_production_batch_ledger_txn",
"pp_production_batch_ledger",
"pp_completion_receipt_item",
"pp_completion_receipt",
"pp_scrap_record",
"pp_operation_report",
"pp_material_issue_item",
"pp_material_issue",
"pp_work_order_material_issue",
"pp_work_order_operation",
"pp_work_order_material",
"pp_work_order",
"wh_special_adjustment_line",
"wh_special_adjustment",
"wh_stocktake_adjustment",
"wh_stocktake_line",
"wh_stocktake_warehouse",
"wh_stocktake",
"wh_inventory_txn",
"wh_stock_balance",
"wh_stock_lot",
"po_purchase_return_item",
"po_purchase_return",
"po_receipt_item",
"po_receipt",
"po_purchase_order_sales_order",
"po_purchase_order_item",
"po_purchase_order",
"mrp_material_demand",
"so_sales_order_item",
"so_sales_order",
"fi_statement_snapshot",
"fi_cost_allocation",
"fi_overhead_entry",
"fi_accounting_period",
"em_maintenance_order",
"em_maintenance_plan",
"em_equipment",
"md_bom_item",
"md_bom",
"md_process_route_operation",
"md_process_route",
"md_work_center",
"md_process",
"md_product",
"md_material",
"md_item",
"md_customer",
"md_supplier",
"sys_broadcast_message",
]
MINIAPP_RESET_TABLES = [
"notice_points",
"notices",
"device_qrcode_batch_tasks",
"device_qrcodes",
"mold_lock_feedbacks",
"report_audit_logs",
"production_report_items",
"production_reports",
"work_session_devices",
"work_sessions",
"reconciliation_ledger_entries",
"products",
"equipment",
"person_attendance_points",
"person_roles",
"personnel",
"attendance_points",
"work_schedules",
]
ACCOUNT_RESET_TABLES = [
"sys_user_role",
"sys_user",
"sys_org_manager_binding",
"sys_org_employee_binding",
"hr_employee",
"sys_department",
]
SKELETON_TABLES = [
"sys_permission",
"sys_role",
"sys_role_permission",
"sys_system_config",
"sys_ai_assistant_config",
"md_unit",
"md_item_category",
"wh_warehouse",
"wh_location",
]
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
summary_path: str | None
counts_before: dict[str, int]
counts_after: dict[str, int]
seeded: dict[str, int | str | bool]
- Step 4: Run table group test
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_initializer_seed.py::SystemInitializerSeedTest::test_table_groups_have_no_duplicates_and_include_expected_tables -q
Expected: PASS.
- Step 5: Commit
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add backend/app/services/system_initializer.py backend/tests/test_system_initializer_seed.py
git commit -m "feat: define system initialization table groups"
Task 3: Implement Skeleton Seeding
Files:
-
Modify:
backend/app/services/system_initializer.py -
Modify:
backend/tests/test_system_initializer_seed.py -
Step 1: Add failing skeleton seed test
Append to backend/tests/test_system_initializer_seed.py:
from datetime import datetime
from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@compiles(BigInteger, "sqlite")
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
_ = type_, compiler, kw
return "INTEGER"
import app.models.document_archive # noqa: E402,F401
import app.models.master_data # noqa: E402,F401
import app.models.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.models.base import Base # noqa: E402
from app.models.master_data import Warehouse # noqa: E402
from app.models.operations import WarehouseLocation # noqa: E402
from app.models.org import Department, Employee, Permission, Role, SystemConfig, User, UserRole # noqa: E402
from app.services.auth import verify_password # noqa: E402
class SystemInitializerSkeletonSeedTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
def tearDown(self) -> None:
self.db.close()
def test_seed_system_skeleton_creates_admin_permissions_config_and_warehouses(self) -> None:
from app.services.system_initializer import SystemInitializeOptions, seed_system_skeleton
options = SystemInitializeOptions(
mode="reset",
company_name="百华",
admin_name="超级管理员",
admin_phone="13800000000",
admin_password="secret123",
smart_operation_report_enabled=False,
confirm_reset=True,
)
result = seed_system_skeleton(self.db, options)
self.db.commit()
admin_user = self.db.scalar(select(User).where(User.username == "13800000000"))
self.assertIsNotNone(admin_user)
self.assertEqual(admin_user.is_super_admin, 1)
self.assertTrue(verify_password("secret123", admin_user.password_hash))
admin_employee = self.db.scalar(select(Employee).where(Employee.mobile == "13800000000"))
self.assertEqual(admin_employee.employee_name, "超级管理员")
root = self.db.scalar(select(Department).where(Department.dept_code == "ORG_ROOT"))
self.assertEqual(root.dept_name, "百华")
self.assertGreater(self.db.query(Permission).count(), 0)
self.assertGreater(self.db.query(Role).count(), 0)
self.assertGreater(self.db.query(UserRole).count(), 0)
self.assertEqual(self.db.query(Warehouse).count(), 6)
self.assertEqual(self.db.query(WarehouseLocation).count(), 6)
prefix = self.db.scalar(select(SystemConfig).where(SystemConfig.config_code == "RAW_MATERIAL_LOT_PREFIX"))
self.assertEqual(prefix.config_value, "YL")
smart = self.db.scalar(select(SystemConfig).where(SystemConfig.config_code == "SMART_OPERATION_REPORT_ENABLED"))
self.assertEqual(smart.config_value, "关闭")
self.assertEqual(result["warehouse_count"], 6)
- Step 2: Run skeleton seed test to verify failure
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_initializer_seed.py::SystemInitializerSkeletonSeedTest::test_seed_system_skeleton_creates_admin_permissions_config_and_warehouses -q
Expected: FAIL because seed_system_skeleton does not exist.
- Step 3: Implement
seed_system_skeleton
Append to backend/app/services/system_initializer.py:
def _now() -> datetime:
return datetime.now().replace(microsecond=0)
def _bool_to_config_value(enabled: bool) -> str:
return "开启" if enabled else "关闭"
def _upsert_system_config(
db: Session,
*,
code: str,
name: str,
value: str,
remark: str,
) -> None:
from app.models.org import SystemConfig
now = _now()
row = db.scalar(select(SystemConfig).where(SystemConfig.config_code == code))
if row is None:
row = 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,
)
db.add(row)
return
row.config_name = name
row.config_value = value
row.remark = remark
row.status = "ACTIVE"
row.updated_at = now
db.add(row)
def _upsert_role(db: Session, role_code: str, role_name: str, remark: str) -> object:
from app.models.org import 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=remark,
created_at=now,
updated_at=now,
)
db.add(row)
db.flush()
return row
row.role_name = role_name
row.role_scope = "SYSTEM"
row.status = "ACTIVE"
row.remark = remark
row.updated_at = now
db.add(row)
db.flush()
return row
def _seed_permissions_and_roles(db: Session) -> dict[str, int]:
from app.models.org import Permission, RolePermission
from app.services.system_permissions import MENU_PERMISSION_TREE
now = _now()
roles = [
_upsert_role(db, "ADMIN", "超级管理员", "系统初始化内置角色"),
_upsert_role(db, "PURCHASER", "采购专员", "系统初始化内置角色"),
_upsert_role(db, "WAREHOUSE", "仓库管理人员", "系统初始化内置角色"),
_upsert_role(db, "SALES", "销售人员", "系统初始化内置角色"),
]
def walk(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(walk(children))
return result
permissions = []
for code, name in walk(MENU_PERMISSION_TREE):
row = db.scalar(select(Permission).where(Permission.permission_code == code))
if row is None:
row = Permission(
permission_code=code,
permission_name=name,
module_code=code.replace("MENU_", ""),
action_code="VIEW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
db.add(row)
db.flush()
else:
row.permission_name = name
row.status = "ACTIVE"
row.updated_at = now
db.add(row)
db.flush()
permissions.append(row)
admin_role = roles[0]
for permission in permissions:
exists_row = db.scalar(
select(RolePermission).where(
RolePermission.role_id == admin_role.id,
RolePermission.permission_id == permission.id,
)
)
if exists_row is None:
db.add(RolePermission(role_id=admin_role.id, permission_id=permission.id, created_at=now))
return {"role_count": len(roles), "permission_count": len(permissions)}
def _seed_units_and_categories(db: Session) -> dict[str, int]:
now = _now()
unit_rows = [
("KG", "kg", 3),
("PCS", "件", 0),
("SET", "套", 0),
]
for code, name, precision in unit_rows:
row = db.execute(text("SELECT id FROM md_unit WHERE unit_code = :code"), {"code": code}).first()
if row is None:
db.execute(
text(
"""
INSERT INTO md_unit (unit_code, unit_name, precision_digits, created_at, updated_at)
VALUES (:code, :name, :precision, :now, :now)
"""
),
{"code": code, "name": name, "precision": precision, "now": now},
)
category_rows = [
("RAW", "原材料", "RAW_MATERIAL"),
("SEMI", "半成品", "SEMI_FINISHED"),
("FINISHED", "成品", "FINISHED_PRODUCT"),
("AUX", "辅料", "AUXILIARY"),
("SCRAP", "废料", "SCRAP"),
]
for code, name, item_type in category_rows:
row = db.execute(text("SELECT id FROM md_item_category WHERE category_code = :code"), {"code": code}).first()
if row is None:
db.execute(
text(
"""
INSERT INTO md_item_category
(category_code, category_name, parent_id, item_type, sort_no, status, created_at, updated_at)
VALUES (:code, :name, NULL, :item_type, 0, 'ACTIVE', :now, :now)
"""
),
{"code": code, "name": name, "item_type": item_type, "now": now},
)
return {"unit_count": len(unit_rows), "category_count": len(category_rows)}
def _seed_warehouses(db: Session) -> dict[str, int]:
now = _now()
for warehouse_type, warehouse_name, location_name in WAREHOUSE_TYPES:
row = db.execute(
text("SELECT id FROM wh_warehouse WHERE warehouse_type = :warehouse_type"),
{"warehouse_type": warehouse_type},
).first()
if row is None:
result = db.execute(
text(
"""
INSERT INTO wh_warehouse
(warehouse_code, warehouse_name, warehouse_type, manager_employee_id, status, remark, created_at, updated_at)
VALUES (:code, :name, :warehouse_type, NULL, 'ACTIVE', '系统初始化默认仓库', :now, :now)
"""
),
{
"code": f"WH-{warehouse_type}",
"name": warehouse_name,
"warehouse_type": warehouse_type,
"now": now,
},
)
warehouse_id = result.lastrowid
else:
warehouse_id = int(row[0])
db.execute(
text(
"""
UPDATE wh_warehouse
SET warehouse_name = :name, status = 'ACTIVE', updated_at = :now
WHERE id = :warehouse_id
"""
),
{"name": warehouse_name, "warehouse_id": warehouse_id, "now": now},
)
location = db.execute(
text("SELECT id FROM wh_location WHERE warehouse_id = :warehouse_id AND is_default = 1"),
{"warehouse_id": warehouse_id},
).first()
if location is None:
db.execute(
text(
"""
INSERT INTO wh_location
(warehouse_id, location_code, location_name, zone_name, is_default, is_locked, status, remark, created_at, updated_at)
VALUES (:warehouse_id, :code, :name, NULL, 1, 0, 'ACTIVE', '系统初始化默认库位', :now, :now)
"""
),
{"warehouse_id": warehouse_id, "code": f"LOC-{warehouse_type}", "name": location_name, "now": now},
)
return {"warehouse_count": len(WAREHOUSE_TYPES), "location_count": len(WAREHOUSE_TYPES)}
def _seed_admin_user(db: Session, options: SystemInitializeOptions) -> dict[str, int | str]:
from app.models.org import Department, Employee, Role, User, UserRole
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,
)
db.add(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.add(root)
db.flush()
employee = db.scalar(select(Employee).where(Employee.employee_code == "EMP-ADMIN-001"))
if employee is None:
employee = Employee(
employee_code="EMP-ADMIN-001",
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,
)
db.add(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.add(employee)
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,
)
db.add(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.add(user)
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:
db.add(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_miniapp_defaults(db: Session, options: SystemInitializeOptions) -> dict[str, int]:
now = _now()
attendance_point = db.execute(
text("SELECT name FROM attendance_points WHERE name = :name"),
{"name": options.company_name},
).first()
if attendance_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},
)
personnel = db.execute(
text("SELECT phone FROM personnel WHERE phone = :phone"),
{"phone": options.admin_phone},
).first()
if personnel 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) -> dict[str, int | str | bool]:
permission_summary = _seed_permissions_and_roles(db)
config_enabled = _bool_to_config_value(options.smart_operation_report_enabled)
_upsert_system_config(
db,
code="RAW_MATERIAL_LOT_PREFIX",
name="原材料库存批次前缀",
value="YL",
remark="系统初始化默认值,可在系统管理中维护",
)
_upsert_system_config(
db,
code="SMART_OPERATION_REPORT_ENABLED",
name="对接智能报工小程序",
value=config_enabled,
remark="开启后显示工序报工并同步小程序报工数据",
)
unit_summary = _seed_units_and_categories(db)
warehouse_summary = _seed_warehouses(db)
admin_summary = _seed_admin_user(db, options)
miniapp_summary = _seed_miniapp_defaults(db, options)
return {
**permission_summary,
**unit_summary,
**warehouse_summary,
**admin_summary,
**miniapp_summary,
"smart_operation_report_enabled": options.smart_operation_report_enabled,
}
This implementation intentionally uses SELECT + INSERT / UPDATE instead of MySQL-only ON DUPLICATE KEY UPDATE, so the same seed tests pass on SQLite and MySQL.
- Step 4: Run skeleton seed test
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_initializer_seed.py -q
Expected: PASS.
- Step 5: Commit
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add backend/app/services/system_initializer.py backend/tests/test_system_initializer_seed.py
git commit -m "feat: seed system initialization skeleton"
Task 4: Implement Reset Safety, Counting, And Truncation
Files:
-
Modify:
backend/app/services/system_initializer.py -
Create:
backend/tests/test_system_initializer_reset.py -
Step 1: Write failing reset protection test
Create backend/tests/test_system_initializer_reset.py:
from __future__ import annotations
import unittest
from sqlalchemy import BigInteger, create_engine, select, text
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@compiles(BigInteger, "sqlite")
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
_ = type_, compiler, kw
return "INTEGER"
import app.models.document_archive # noqa: E402,F401
import app.models.master_data # noqa: E402,F401
import app.models.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.models.base import Base # noqa: E402
from app.models.org import Department, User # noqa: E402
class SystemInitializerResetTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
def tearDown(self) -> None:
self.db.close()
def test_reset_requires_explicit_confirmation(self) -> None:
from app.services.system_initializer import SystemInitializeOptions, reset_existing_database
options = SystemInitializeOptions(
mode="reset",
company_name="百华",
admin_name="超级管理员",
admin_phone="13800000000",
admin_password="secret123",
confirm_reset=False,
)
with self.assertRaisesRegex(ValueError, "confirm-reset"):
reset_existing_database(self.db, options)
def test_reset_clears_test_rows_and_rebuilds_admin(self) -> None:
from app.services.system_initializer import SystemInitializeOptions, reset_existing_database
self.db.execute(
text(
"""
INSERT INTO md_customer
(customer_code, customer_name, status, credit_days, created_at, updated_at)
VALUES ('CUS-TEST', '嘉恒测试客户', 'ACTIVE', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
"""
)
)
self.db.execute(
text(
"""
INSERT INTO sys_department
(dept_code, dept_name, org_node_type, dept_type, status, sort_no, created_at, updated_at)
VALUES ('OLD', '嘉恒测试部门', 'DEPARTMENT', 'ADMIN', 'ACTIVE', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
"""
)
)
self.db.commit()
options = SystemInitializeOptions(
mode="reset",
company_name="百华",
admin_name="超级管理员",
admin_phone="13800000000",
admin_password="secret123",
smart_operation_report_enabled=True,
confirm_reset=True,
)
result = reset_existing_database(self.db, options)
self.db.commit()
self.assertEqual(self.db.execute(text("SELECT COUNT(*) FROM md_customer")).scalar(), 0)
self.assertIsNone(self.db.scalar(select(Department).where(Department.dept_code == "OLD")))
admin_user = self.db.scalar(select(User).where(User.username == "13800000000"))
self.assertIsNotNone(admin_user)
self.assertEqual(admin_user.is_super_admin, 1)
self.assertEqual(result.seeded["admin_username"], "13800000000")
- Step 2: Run reset tests to verify failure
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_initializer_reset.py -q
Expected: FAIL because reset_existing_database does not exist.
- Step 3: Implement table helpers and reset flow
Append to backend/app/services/system_initializer.py:
def _existing_tables(db: Session) -> set[str]:
return set(inspect(db.bind).get_table_names())
def _count_table(db: Session, table_name: str) -> int:
return int(db.execute(text(f"SELECT COUNT(*) FROM `{table_name}`")).scalar() or 0)
def count_existing_tables(db: Session, table_names: Iterable[str]) -> dict[str, int]:
existing = _existing_tables(db)
return {table: _count_table(db, table) for table in table_names if table in existing}
def _delete_all_rows(db: Session, table_name: str) -> None:
db.execute(text(f"DELETE FROM `{table_name}`"))
def _reset_autoincrement_if_supported(db: Session, table_name: str) -> None:
dialect = db.bind.dialect.name if db.bind is not None else ""
if dialect == "mysql":
db.execute(text(f"ALTER TABLE `{table_name}` AUTO_INCREMENT = 1"))
elif dialect == "sqlite":
db.execute(text("DELETE FROM sqlite_sequence WHERE name = :table_name"), {"table_name": table_name})
def _clear_tables(db: Session, table_names: Iterable[str]) -> None:
existing = _existing_tables(db)
dialect = db.bind.dialect.name if db.bind is not None else ""
if dialect == "mysql":
db.execute(text("SET FOREIGN_KEY_CHECKS=0"))
try:
for table_name in table_names:
if table_name not in existing:
continue
_delete_all_rows(db, table_name)
_reset_autoincrement_if_supported(db, table_name)
finally:
if dialect == "mysql":
db.execute(text("SET FOREIGN_KEY_CHECKS=1"))
def reset_existing_database(db: Session, options: SystemInitializeOptions) -> SystemInitializeResult:
if not options.confirm_reset:
raise ValueError("reset mode requires --confirm-reset")
tables_to_clear = BUSINESS_RESET_TABLES + MINIAPP_RESET_TABLES + ACCOUNT_RESET_TABLES
counts_before = count_existing_tables(db, tables_to_clear + SKELETON_TABLES)
_clear_tables(db, tables_to_clear)
seeded = seed_system_skeleton(db, options)
counts_after = count_existing_tables(db, tables_to_clear + SKELETON_TABLES)
return SystemInitializeResult(
mode="reset",
database="",
backup_path=None,
summary_path=None,
counts_before=counts_before,
counts_after=counts_after,
seeded=seeded,
)
If SQLite raises on raw SQL strings in the test, wrap raw inserts with sqlalchemy.text(...).
- Step 4: Run reset tests
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_initializer_reset.py tests/test_system_initializer_seed.py -q
Expected: PASS.
- Step 5: Commit
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add backend/app/services/system_initializer.py backend/tests/test_system_initializer_reset.py
git commit -m "feat: reset existing system data safely"
Task 5: Implement Fresh Schema Creation
Files:
-
Modify:
backend/app/services/system_initializer.py -
Modify:
backend/tests/test_system_initializer_seed.py -
Step 1: Add failing fresh create-all test
Append to backend/tests/test_system_initializer_seed.py:
class SystemInitializerFreshTest(unittest.TestCase):
def test_create_all_tables_on_engine_registers_required_tables(self) -> None:
from app.services.system_initializer import create_all_schema_tables
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
create_all_schema_tables(engine)
inspector = inspect(engine)
table_names = set(inspector.get_table_names())
self.assertIn("sys_user", table_names)
self.assertIn("wh_stock_lot", table_names)
self.assertIn("work_sessions", table_names)
self.assertIn("report_audit_logs", table_names)
Also import inspect at the top:
from sqlalchemy import BigInteger, create_engine, inspect, select
- Step 2: Run fresh test to verify failure
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_initializer_seed.py::SystemInitializerFreshTest::test_create_all_tables_on_engine_registers_required_tables -q
Expected: FAIL because create_all_schema_tables does not exist.
- Step 3: Implement model import and create-all helpers
Append to backend/app/services/system_initializer.py:
def import_all_models() -> None:
import app.models.document_archive # noqa: F401
import app.models.master_data # noqa: F401
import app.models.miniapp # noqa: F401
import app.models.operations # noqa: F401
import app.models.org # noqa: F401
import app.models.planning # noqa: F401
import app.models.sales # noqa: F401
def create_all_schema_tables(engine: Engine) -> None:
import_all_models()
Base.metadata.create_all(engine)
- Step 4: Implement MySQL database creation helper
Append:
def create_database_if_missing(settings: Settings) -> None:
server_url = settings.mysql_dsn.set(database=None)
server_engine = create_engine(server_url, pool_pre_ping=True, future=True)
with server_engine.begin() as conn:
conn.execute(
text(
f"CREATE DATABASE IF NOT EXISTS `{settings.mysql_database}` "
"DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci"
)
)
server_engine.dispose()
def fresh_initialize_database(engine: Engine, options: SystemInitializeOptions) -> SystemInitializeResult:
create_all_schema_tables(engine)
with Session(engine) as db:
seeded = seed_system_skeleton(db, options)
db.commit()
counts_after = count_existing_tables(db, SKELETON_TABLES + MINIAPP_RESET_TABLES)
return SystemInitializeResult(
mode="fresh",
database="",
backup_path=None,
summary_path=None,
counts_before={},
counts_after=counts_after,
seeded=seeded,
)
- Step 5: Run fresh and seed tests
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_initializer_models.py tests/test_system_initializer_seed.py -q
Expected: PASS.
- Step 6: Commit
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add backend/app/services/system_initializer.py backend/tests/test_system_initializer_seed.py
git commit -m "feat: create fresh system schema"
Task 6: Add Backup And Summary Report Generation
Files:
-
Modify:
backend/app/services/system_initializer.py -
Modify:
backend/tests/test_system_initializer_reset.py -
Step 1: Add failing summary write test
Append to backend/tests/test_system_initializer_reset.py:
import tempfile
from pathlib import Path
class SystemInitializerReportTest(unittest.TestCase):
def test_write_summary_file_contains_mode_and_counts(self) -> None:
from app.services.system_initializer import SystemInitializeResult, write_summary_report
with tempfile.TemporaryDirectory() as tmpdir:
result = SystemInitializeResult(
mode="reset",
database="jiaheng_erp",
backup_path="/tmp/backup.sql",
summary_path=None,
counts_before={"md_customer": 2},
counts_after={"md_customer": 0},
seeded={"admin_username": "13800000000"},
)
output = write_summary_report(Path(tmpdir), result)
content = output.read_text(encoding="utf-8")
self.assertIn('"mode": "reset"', content)
self.assertIn('"md_customer": 2', content)
self.assertIn('"admin_username": "13800000000"', content)
- Step 2: Run report test to verify failure
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_initializer_reset.py::SystemInitializerReportTest::test_write_summary_file_contains_mode_and_counts -q
Expected: FAIL because write_summary_report does not exist.
- Step 3: Implement report writing
Append to backend/app/services/system_initializer.py:
def _timestamp() -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S")
def default_output_dir() -> Path:
return Path(__file__).resolve().parents[3] / "outputs" / "cleanup_backups"
def write_summary_report(output_dir: Path, result: SystemInitializeResult) -> Path:
output_dir.mkdir(parents=True, exist_ok=True)
summary_path = output_dir / f"system_init_summary_{_timestamp()}.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": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
summary_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return summary_path
- Step 4: Implement MySQL backup helper
Append:
def backup_mysql_database(settings: Settings, output_dir: Path) -> Path:
output_dir.mkdir(parents=True, exist_ok=True)
backup_path = output_dir / f"system_init_backup_{_timestamp()}.sql"
command = [
"mysqldump",
"--default-character-set=utf8mb4",
"-h",
settings.mysql_host,
"-P",
str(settings.mysql_port),
"-u",
settings.mysql_user,
f"-p{settings.mysql_password}",
settings.mysql_database,
]
with backup_path.open("wb") as handle:
subprocess.run(command, stdout=handle, stderr=subprocess.PIPE, check=True)
return backup_path
This helper is only used for MySQL-backed CLI execution. Unit tests do not run mysqldump.
- Step 5: Wire reset result to include backup/report in service helper
Append:
def reset_existing_database_with_backup(
engine: Engine,
settings: Settings,
options: SystemInitializeOptions,
*,
output_dir: Path | None = None,
) -> SystemInitializeResult:
backup_dir = output_dir or default_output_dir()
backup_path = backup_mysql_database(settings, backup_dir)
with Session(engine) as db:
result = reset_existing_database(db, options)
db.commit()
result = SystemInitializeResult(
mode=result.mode,
database=settings.mysql_database,
backup_path=str(backup_path),
summary_path=None,
counts_before=result.counts_before,
counts_after=result.counts_after,
seeded=result.seeded,
)
summary_path = write_summary_report(backup_dir, result)
return SystemInitializeResult(
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,
)
- Step 6: Run report and reset tests
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_initializer_reset.py -q
Expected: PASS.
- Step 7: Commit
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add backend/app/services/system_initializer.py backend/tests/test_system_initializer_reset.py
git commit -m "feat: add initialization backup and reports"
Task 7: Add CLI Entry Point
Files:
-
Create:
backend/scripts/system_initialize.py -
Create:
backend/tests/test_system_initializer_cli.py -
Step 1: Write failing CLI parse test
Create backend/tests/test_system_initializer_cli.py:
from __future__ import annotations
import unittest
class SystemInitializerCliTest(unittest.TestCase):
def test_parse_reset_arguments(self) -> None:
from scripts.system_initialize import parse_args
args = parse_args(
[
"reset",
"--company-name",
"百华",
"--admin-name",
"超级管理员",
"--admin-phone",
"13800000000",
"--admin-password",
"secret123",
"--confirm-reset",
"--smart-operation-report",
"off",
]
)
self.assertEqual(args.mode, "reset")
self.assertEqual(args.company_name, "百华")
self.assertEqual(args.admin_phone, "13800000000")
self.assertTrue(args.confirm_reset)
self.assertEqual(args.smart_operation_report, "off")
if __name__ == "__main__":
unittest.main()
- Step 2: Run CLI test to verify failure
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_initializer_cli.py -q
Expected: FAIL because backend/scripts/system_initialize.py does not exist.
- Step 3: Create scripts package and CLI
Create directory backend/scripts if it does not exist.
Create backend/scripts/__init__.py:
"""Operational scripts for ForgeFlow ERP backend."""
Create backend/scripts/system_initialize.py:
from __future__ import annotations
import argparse
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from sqlalchemy import create_engine # noqa: E402
from app.core.config import get_settings # noqa: E402
from app.services.system_initializer import ( # noqa: E402
SystemInitializeOptions,
create_database_if_missing,
fresh_initialize_database,
reset_existing_database_with_backup,
)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Initialize ForgeFlow ERP database for customer deployment.")
subparsers = parser.add_subparsers(dest="mode", required=True)
def add_common(subparser: argparse.ArgumentParser) -> None:
subparser.add_argument("--company-name", required=True)
subparser.add_argument("--admin-name", required=True)
subparser.add_argument("--admin-phone", required=True)
subparser.add_argument("--admin-password", required=True)
subparser.add_argument(
"--smart-operation-report",
choices=["on", "off"],
default="on",
help="Whether to enable smart miniapp operation report integration.",
)
subparser.add_argument("--delete-files", action="store_true")
subparser.add_argument("--allow-any-database", action="store_true")
fresh = subparsers.add_parser("fresh", help="Create a new database/schema and seed clean system skeleton.")
add_common(fresh)
reset = subparsers.add_parser("reset", help="Reset an existing database after backup.")
add_common(reset)
reset.add_argument("--confirm-reset", action="store_true")
return parser.parse_args(argv)
def _options_from_args(args: argparse.Namespace) -> SystemInitializeOptions:
return SystemInitializeOptions(
mode=args.mode,
company_name=args.company_name,
admin_name=args.admin_name,
admin_phone=args.admin_phone,
admin_password=args.admin_password,
smart_operation_report_enabled=args.smart_operation_report == "on",
confirm_reset=bool(getattr(args, "confirm_reset", False)),
delete_files=bool(args.delete_files),
allow_any_database=bool(args.allow_any_database),
)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
settings = get_settings()
options = _options_from_args(args)
print(f"mode={options.mode}")
print(f"database={settings.mysql_host}:{settings.mysql_port}/{settings.mysql_database}")
if options.mode == "fresh":
create_database_if_missing(settings)
engine = create_engine(settings.mysql_dsn, pool_pre_ping=True, future=True)
result = fresh_initialize_database(engine, options)
else:
engine = create_engine(settings.mysql_dsn, pool_pre_ping=True, future=True)
result = reset_existing_database_with_backup(engine, settings, options)
print(f"backup_path={result.backup_path or ''}")
print(f"summary_path={result.summary_path or ''}")
print(f"admin_username={result.seeded.get('admin_username', '')}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
- Step 4: Run CLI test
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_initializer_cli.py -q
Expected: PASS.
- Step 5: Run CLI help manually
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python scripts/system_initialize.py --help
.venv/bin/python scripts/system_initialize.py reset --help
Expected: Both commands print usage text and exit 0.
- Step 6: Commit
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add backend/scripts backend/tests/test_system_initializer_cli.py
git commit -m "feat: add system initialization CLI"
Task 8: Add Optional File Cleanup
Files:
-
Modify:
backend/app/services/system_initializer.py -
Modify:
backend/tests/test_system_initializer_reset.py -
Step 1: Add file cleanup test
Append to backend/tests/test_system_initializer_reset.py:
class SystemInitializerFileCleanupTest(unittest.TestCase):
def test_delete_managed_files_removes_only_known_children(self) -> None:
from app.services.system_initializer import delete_managed_files
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
archive_dir = root / "document_archives"
photo_dir = root / "uploads" / "logistics"
archive_dir.mkdir(parents=True)
photo_dir.mkdir(parents=True)
(archive_dir / "a.pdf").write_text("pdf", encoding="utf-8")
(photo_dir / "b.png").write_text("png", encoding="utf-8")
summary = delete_managed_files([archive_dir, photo_dir])
self.assertEqual(summary["deleted_file_count"], 2)
self.assertFalse((archive_dir / "a.pdf").exists())
self.assertFalse((photo_dir / "b.png").exists())
self.assertTrue(archive_dir.exists())
self.assertTrue(photo_dir.exists())
- Step 2: Run file cleanup test to verify failure
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_initializer_reset.py::SystemInitializerFileCleanupTest::test_delete_managed_files_removes_only_known_children -q
Expected: FAIL because delete_managed_files does not exist.
- Step 3: Implement file cleanup helper
Append to backend/app/services/system_initializer.py:
def delete_managed_files(directories: list[Path]) -> dict[str, int]:
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():
child.unlink()
deleted_file_count += 1
elif child.is_dir():
shutil.rmtree(child)
deleted_dir_count += 1
return {"deleted_file_count": deleted_file_count, "deleted_dir_count": deleted_dir_count}
Do not call this helper unless options.delete_files is True.
- Step 4: Wire file cleanup into CLI result
Modify reset_existing_database_with_backup after write_summary_report:
file_cleanup = {}
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.seeded.update(file_cleanup)
If SystemInitializeResult.seeded is frozen through dataclass immutability concerns, construct a new seeded = {**result.seeded, **file_cleanup} dict and pass it into the returned result.
- Step 5: Run file cleanup tests
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_initializer_reset.py -q
Expected: PASS.
- Step 6: Commit
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add backend/app/services/system_initializer.py backend/tests/test_system_initializer_reset.py
git commit -m "feat: support optional initialization file cleanup"
Task 9: Add End-To-End Local Verification Commands
Files:
-
Create:
docs/superpowers/plans/2026-06-14-system-initialization-bootstrap-runbook.md -
Modify:
docs/superpowers/plans/2026-06-14-system-initialization-bootstrap.md -
Step 1: Create runbook
Create docs/superpowers/plans/2026-06-14-system-initialization-bootstrap-runbook.md:
# System Initialization Runbook
## Fresh Initialization On A New Database
```bash
cd /home/souplearn/ERP/ForgeFlow-ERP/backend
source .venv/bin/activate
python scripts/system_initialize.py fresh \
--company-name 百华 \
--admin-name 超级管理员 \
--admin-phone 13800000000 \
--admin-password '正式密码' \
--smart-operation-report on
Reset Existing Test Database
cd /home/souplearn/ERP/ForgeFlow-ERP/backend
source .venv/bin/activate
python scripts/system_initialize.py reset \
--company-name 百华 \
--admin-name 超级管理员 \
--admin-phone 13800000000 \
--admin-password '正式密码' \
--smart-operation-report on \
--confirm-reset
Verification
curl -s http://127.0.0.1:8000/api/system/health | python3 -m json.tool
mysql --default-character-set=utf8mb4 -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" -e "
SELECT COUNT(*) AS customer_count FROM md_customer;
SELECT COUNT(*) AS supplier_count FROM md_supplier;
SELECT COUNT(*) AS stock_lot_count FROM wh_stock_lot;
SELECT COUNT(*) AS sales_order_count FROM so_sales_order;
SELECT COUNT(*) AS admin_count FROM sys_user WHERE username='13800000000' AND is_super_admin=1;
"
Expected:
- Health endpoint reports database connected.
- Business tables return
0. - Admin count returns
1. - ERP login succeeds with the initialized admin phone and password.
- [ ] **Step 2: Add runbook pointer to implementation plan**
Append to this implementation plan:
```markdown
## Runbook
Deployment commands are maintained in:
- `docs/superpowers/plans/2026-06-14-system-initialization-bootstrap-runbook.md`
- Step 3: Commit
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add docs/superpowers/plans/2026-06-14-system-initialization-bootstrap-runbook.md docs/superpowers/plans/2026-06-14-system-initialization-bootstrap.md
git commit -m "docs: add system initialization runbook"
Task 10: Full Verification Before Server Use
Files:
-
No source changes expected.
-
Step 1: Run focused tests
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest \
tests/test_system_initializer_models.py \
tests/test_system_initializer_seed.py \
tests/test_system_initializer_reset.py \
tests/test_system_initializer_cli.py \
-q
Expected: PASS.
- Step 2: Run broader backend smoke tests
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest \
tests/test_system_permission_management.py \
tests/test_smart_operation_report_config.py \
tests/test_selected_stock_lot_production_issue.py \
tests/test_purchase_order_document_archive.py \
tests/test_warehouse_operation_document_archive.py \
-q
Expected: PASS.
- Step 3: Run frontend build
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
Expected: PASS.
- Step 4: Review git diff
Run:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short --branch
git log --oneline -n 8
Expected: working tree clean after task commits, branch on BH_DEV.
- Step 5: Ask user before running against server database
Do not execute reset against the server database without explicit user approval at action time. The next message must state the exact command, target host/database, whether files will be deleted, and backup path behavior.
Spec Coverage Self-Review
freshfull database/schema initialization is covered by Tasks 1, 3, 5, 7, and 10.resetexisting database initialization is covered by Tasks 2, 3, 4, 6, 7, 8, and 10.- ERP table coverage is covered by Task 1 metadata tests and Task 5
create_all_schema_tables. - Miniapp table coverage is covered by Task 1 model expansion and tests.
- "Only keep one super admin" is covered by Task 3 seed and Task 4 reset test.
- Backup and JSON report are covered by Task 6.
- Optional file deletion is covered by Task 8 and defaults to off.
- Runbook for future customer deployment is covered by Task 9.
- Server execution safety gate is covered by Task 10.
Runbook
Deployment commands are maintained in:
docs/superpowers/plans/2026-06-14-system-initialization-bootstrap-runbook.md