2099 lines
71 KiB
Markdown
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`
|