ForgeFlow-ERP/docs/superpowers/plans/2026-06-14-system-initialization-bootstrap.md

2099 lines
71 KiB
Markdown

# 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 `fresh` and `reset`, validates flags, calls the service, prints report paths.
- 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.metadata` is complete in initializer and tests.
- Create: `backend/tests/test_system_initializer_models.py`
- Verifies all required ERP and miniapp table names are registered in `Base.metadata`.
- 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`:
```python
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:
```bash
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:
```python
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:
```python
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:
```python
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`: add `process_unit_price_yuan`.
- `MiniAppProductionReport`: add `review_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`: add `operator_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:
```python
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:
```bash
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**
```bash
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:
```python
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:
```bash
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`:
```python
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:
```bash
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**
```bash
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`:
```python
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:
```bash
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`:
```python
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:
```bash
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**
```bash
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`:
```python
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:
```bash
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`:
```python
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:
```bash
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**
```bash
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`:
```python
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:
```python
from sqlalchemy import BigInteger, create_engine, inspect, select
```
- [ ] **Step 2: Run fresh test to verify failure**
Run:
```bash
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`:
```python
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:
```python
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:
```bash
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**
```bash
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`:
```python
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:
```bash
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`:
```python
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:
```python
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:
```python
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:
```bash
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**
```bash
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`:
```python
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:
```bash
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`:
```python
"""Operational scripts for ForgeFlow ERP backend."""
```
Create `backend/scripts/system_initialize.py`:
```python
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:
```bash
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:
```bash
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**
```bash
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`:
```python
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:
```bash
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`:
```python
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`:
```python
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:
```bash
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**
```bash
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`:
```markdown
# 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
```bash
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
```bash
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**
```bash
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:
```bash
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:
```bash
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:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected: PASS.
- [ ] **Step 4: Review git diff**
Run:
```bash
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
- `fresh` full database/schema initialization is covered by Tasks 1, 3, 5, 7, and 10.
- `reset` existing 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`