diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 08077bd..bab4c4c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,4 +1,6 @@ -from app.models.master_data import Bom, BomItem, Item, Material, Product, StockBalance, Warehouse +from app.models import miniapp as _miniapp # noqa: F401 +from app.models import operations as _operations # noqa: F401 +from app.models.master_data import Bom, BomItem, Item, ItemCategory, Material, Product, StockBalance, Warehouse from app.models.document_archive import DocumentArchive from app.models.org import Department, Employee, Permission, Role, RolePermission, User, UserRole from app.models.planning import MaterialDemand @@ -12,6 +14,7 @@ __all__ = [ "Bom", "BomItem", "Item", + "ItemCategory", "Material", "MaterialDemand", "Permission", diff --git a/backend/app/models/master_data.py b/backend/app/models/master_data.py index 53fafe9..7be98b7 100644 --- a/backend/app/models/master_data.py +++ b/backend/app/models/master_data.py @@ -17,6 +17,20 @@ class Unit(Base): updated_at: Mapped[object] = mapped_column(DateTime) +class ItemCategory(Base): + __tablename__ = "md_item_category" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + category_code: Mapped[str] = mapped_column(String(50)) + category_name: Mapped[str] = mapped_column(String(100)) + parent_id: Mapped[int | None] = mapped_column(ForeignKey("md_item_category.id"), nullable=True) + item_type: Mapped[str] = mapped_column(String(32)) + sort_no: Mapped[int] = mapped_column(Integer, server_default=text("0")) + 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 Item(Base): __tablename__ = "md_item" diff --git a/backend/app/models/miniapp.py b/backend/app/models/miniapp.py index 699ff03..b53374f 100644 --- a/backend/app/models/miniapp.py +++ b/backend/app/models/miniapp.py @@ -1,6 +1,6 @@ from __future__ import annotations -from sqlalchemy import BigInteger, Boolean, Date, DateTime, DECIMAL, ForeignKey, Integer, String, Text, text +from sqlalchemy import JSON, BigInteger, Boolean, Date, DateTime, DECIMAL, ForeignKey, Index, Integer, String, Text, text from sqlalchemy.orm import Mapped, mapped_column from app.models.base import Base @@ -13,6 +13,16 @@ class MiniAppAttendancePoint(Base): latitude: Mapped[float | None] = mapped_column(DECIMAL(10, 7), nullable=True) longitude: Mapped[float | None] = mapped_column(DECIMAL(10, 7), nullable=True) radius_meters: Mapped[int] = mapped_column(Integer, server_default=text("500")) + 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'")) remark: Mapped[str | None] = mapped_column(String(500), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, server_default=text("1")) created_at: Mapped[object] = mapped_column(DateTime) @@ -49,7 +59,7 @@ class MiniAppPersonAttendancePoint(Base): class MiniAppProduct(Base): __tablename__ = "products" - attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, server_default=text("'宁波百华智能科技有限公司'")) + attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, server_default=text("''")) project_no: Mapped[str] = mapped_column(String(64), primary_key=True) product_name: Mapped[str] = mapped_column(String(255), primary_key=True) device_no: Mapped[str] = mapped_column(String(64), primary_key=True) @@ -64,18 +74,96 @@ class MiniAppProduct(Base): waste_price_yuan_per_kg: Mapped[float | None] = mapped_column(DECIMAL(12, 4), nullable=True) stamping_method: Mapped[str | None] = mapped_column(String(128), nullable=True) operator_count: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("1")) + process_unit_price_yuan: Mapped[float] = mapped_column(DECIMAL(12, 4), server_default=text("0")) standard_beat: Mapped[float] = mapped_column(DECIMAL(12, 3), server_default=text("0")) standard_workload: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0")) created_at: Mapped[object] = mapped_column(DateTime) updated_at: Mapped[object] = mapped_column(DateTime) + __table_args__ = ( + Index("idx_products_device_no", "device_no"), + Index("idx_products_material_code", "material_code"), + ) + + +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, server_default=text("1")) + 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) + + __table_args__ = ( + Index("idx_sessions_employee_status", "employee_phone", "status"), + ) + + +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) + + __table_args__ = ( + Index("idx_session_devices_session", "session_id"), + Index("idx_session_devices_device", "device_no"), + Index("idx_session_devices_release", "released_at"), + ) + class MiniAppProductionReport(Base): __tablename__ = "production_reports" - id: Mapped[int] = mapped_column(BigInteger, primary_key=True) - attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("'宁波百华智能科技有限公司'")) - session_id: Mapped[int] = mapped_column(BigInteger) + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("''")) + session_id: Mapped[int] = mapped_column(ForeignKey("work_sessions.id"), unique=True) employee_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone")) report_date: Mapped[object] = mapped_column(Date) start_at: Mapped[object] = mapped_column(DateTime) @@ -94,16 +182,32 @@ class MiniAppProductionReport(Base): reviewer_phone: Mapped[str | None] = mapped_column(String(20), nullable=True) reviewed_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) reject_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) + review_remark: Mapped[str | None] = mapped_column(String(500), nullable=True) submitted_at: Mapped[object] = mapped_column(DateTime) + is_system_auto_submitted: Mapped[bool] = mapped_column(Boolean, server_default=text("0")) + auto_submit_reason: Mapped[str | None] = mapped_column(String(120), nullable=True) + is_multi_person_assistant: Mapped[bool] = mapped_column(Boolean, server_default=text("0")) + multi_person_source_report_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + is_voided: Mapped[bool] = mapped_column(Boolean, server_default=text("0")) + voided_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + voided_by: Mapped[str | None] = mapped_column(ForeignKey("personnel.phone"), nullable=True) + unvoid_deadline_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) updated_at: Mapped[object] = mapped_column(DateTime) + __table_args__ = ( + Index("idx_reports_employee_date", "employee_phone", "report_date"), + Index("idx_reports_status_date", "status", "report_date"), + Index("idx_reports_reviewer", "reviewer_phone", "reviewed_at"), + Index("idx_reports_voided", "is_voided", "voided_at"), + ) + class MiniAppProductionReportItem(Base): __tablename__ = "production_report_items" - id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) report_id: Mapped[int] = mapped_column(ForeignKey("production_reports.id")) - attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("'宁波百华智能科技有限公司'")) + attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("''")) device_no: Mapped[str] = mapped_column(String(64)) project_no: Mapped[str] = mapped_column(String(64)) product_name: Mapped[str] = mapped_column(String(255)) @@ -112,6 +216,8 @@ class MiniAppProductionReportItem(Base): raw_material_batch_no: Mapped[str | None] = mapped_column(String(128), nullable=True) process_name: Mapped[str | None] = mapped_column(String(128), nullable=True) stamping_method: Mapped[str | None] = mapped_column(String(128), nullable=True) + operator_count: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("1")) + process_unit_price_yuan: Mapped[float] = mapped_column(DECIMAL(12, 4), server_default=text("0")) standard_beat: Mapped[float] = mapped_column(DECIMAL(12, 3), server_default=text("0")) standard_workload: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0")) good_qty: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0")) @@ -119,13 +225,95 @@ class MiniAppProductionReportItem(Base): scrap_qty: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0")) allocated_minutes: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("0")) started_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + remark: Mapped[str | None] = mapped_column(String(500), nullable=True) created_at: Mapped[object] = mapped_column(DateTime) + __table_args__ = ( + Index("idx_report_items_report", "report_id"), + Index("idx_report_items_product", "project_no", "product_name"), + ) + + +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) + + __table_args__ = ( + Index("idx_audit_report", "report_id"), + Index("idx_audit_reviewer", "reviewer_phone", "created_at"), + ) + + +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) + + __table_args__ = ( + Index("idx_mold_lock_feedback_point_read", "attendance_point_name", "read_at", "created_at"), + Index("idx_mold_lock_feedback_status", "status", "created_at"), + ) + + +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 MiniAppDeviceQRCodeBatchTask(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) + + __table_args__ = ( + Index("idx_device_qrcode_batch_tasks_creator", "created_by", "created_at"), + Index("idx_device_qrcode_batch_tasks_status", "status", "created_at"), + ) + class MiniAppNotice(Base): __tablename__ = "notices" - id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) title: Mapped[str] = mapped_column(String(120)) content: Mapped[str] = mapped_column(Text) sort_order: Mapped[int] = mapped_column(Integer, server_default=text("0")) @@ -141,3 +329,22 @@ class MiniAppNoticePoint(Base): notice_id: Mapped[int] = mapped_column(ForeignKey("notices.id"), primary_key=True) attendance_point_name: Mapped[str] = mapped_column(ForeignKey("attendance_points.name"), primary_key=True) created_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) + + __table_args__ = ( + Index("idx_reconciliation_product", "product_name"), + Index("idx_reconciliation_updated", "updated_at"), + ) diff --git a/backend/app/models/operations.py b/backend/app/models/operations.py index 87f7bd1..02ed705 100644 --- a/backend/app/models/operations.py +++ b/backend/app/models/operations.py @@ -559,6 +559,39 @@ class WorkOrderMaterialIssue(Base): updated_at: Mapped[object] = mapped_column(DateTime) +class MaterialIssue(Base): + __tablename__ = "pp_material_issue" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + issue_no: Mapped[str] = mapped_column(String(50)) + work_order_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order.id")) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + issue_time: Mapped[object] = mapped_column(DateTime) + issuer_employee_id: Mapped[int | None] = mapped_column(ForeignKey("hr_employee.id"), nullable=True) + status: Mapped[str] = mapped_column(String(32), server_default=text("'CREATED'")) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class MaterialIssueItem(Base): + __tablename__ = "pp_material_issue_item" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + material_issue_id: Mapped[int] = mapped_column(ForeignKey("pp_material_issue.id")) + work_order_material_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order_material.id")) + material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + lot_id: Mapped[int] = mapped_column(ForeignKey("wh_stock_lot.id")) + location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True) + issue_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + issue_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + status: Mapped[str] = mapped_column(String(32), server_default=text("'POSTED'")) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + class ProductionBatchLedger(Base): __tablename__ = "pp_production_batch_ledger" __table_args__ = ( diff --git a/backend/app/services/system_initializer.py b/backend/app/services/system_initializer.py new file mode 100644 index 0000000..b9f8a30 --- /dev/null +++ b/backend/app/services/system_initializer.py @@ -0,0 +1,1040 @@ +from __future__ import annotations + +import json +import importlib +import pkgutil +import shutil +import subprocess +import tempfile +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Iterable + +import app.models # noqa: F401 +from sqlalchemy import create_engine, func, inspect, select, text +from sqlalchemy.engine import Engine, URL +from sqlalchemy.orm import Session + +import app.models as models_package +from app.models.base import Base +from app.models.master_data import ItemCategory, Unit, Warehouse +from app.models.operations import WarehouseLocation +from app.models.org import AiAssistantConfig, Department, Employee, Permission, Role, RolePermission, SystemConfig, User, UserRole +from app.services.auth import hash_password +from app.services.system_permissions import MENU_PERMISSION_TREE + + +BUSINESS_RESET_TABLES = [ + "pp_scrap_record", + "po_receipt_item", + "po_purchase_return_item", + "rt_return_disposition", + "pp_work_order_material_issue", + "pp_operation_report", + "pp_material_issue_item", + "pp_completion_receipt_item", + "po_purchase_order_item", + "wh_stocktake_adjustment", + "wh_special_adjustment_line", + "rt_return_item", + "pp_work_order_operation", + "pp_work_order_material", + "pp_production_batch_ledger_txn", + "pp_material_issue", + "pp_completion_receipt", + "mrp_material_demand", + "fi_cost_allocation", + "em_maintenance_order", + "wh_stocktake_line", + "wh_inventory_txn", + "so_delivery_item", + "rt_return_order", + "pp_work_order", + "pp_production_batch_ledger", + "md_process_route_operation", + "md_bom_item", + "em_maintenance_plan", + "wh_stock_lot", + "wh_stock_balance", + "wh_special_adjustment", + "sys_broadcast_message", + "so_sales_order_item", + "so_delivery", + "po_receipt", + "po_purchase_return", + "po_purchase_order_sales_order", + "md_product", + "md_process_route", + "md_material", + "md_bom", + "fi_statement_snapshot", + "fi_overhead_entry", + "em_equipment", + "wh_stocktake_warehouse", + "so_sales_order", + "po_purchase_order", + "md_work_center", + "md_item", + "wh_stocktake", + "md_supplier", + "md_process", + "md_customer", + "fi_accounting_period", + "document_archives", +] + +MINIAPP_RESET_TABLES = [ + "report_audit_logs", + "production_report_items", + "mold_lock_feedbacks", + "work_session_devices", + "production_reports", + "notice_points", + "work_sessions", + "person_roles", + "person_attendance_points", + "notices", + "device_qrcodes", + "device_qrcode_batch_tasks", + "work_schedules", + "reconciliation_ledger_entries", + "products", + "personnel", + "equipment", + "attendance_points", +] + +ACCOUNT_RESET_TABLES = [ + "sys_user_role", + "sys_user", + "sys_org_manager_binding", + "sys_org_employee_binding", + "sys_department", + "hr_employee", +] + +SKELETON_TABLES = [ + "sys_ai_assistant_config", + "wh_location", + "sys_role_permission", + "wh_warehouse", + "sys_system_config", + "sys_role", + "sys_permission", + "md_unit", + "md_item_category", +] + +PRE_DELETE_NULLABLE_FKS = { + "hr_employee": ["manager_employee_id"], + "sys_department": ["manager_employee_id"], +} + +RESET_TABLES_IN_DELETE_ORDER = [ + "pp_scrap_record", + "po_receipt_item", + "po_purchase_return_item", + "rt_return_disposition", + "pp_work_order_material_issue", + "pp_operation_report", + "pp_material_issue_item", + "pp_completion_receipt_item", + "po_purchase_order_item", + "wh_stocktake_adjustment", + "wh_special_adjustment_line", + "rt_return_item", + "pp_work_order_operation", + "pp_work_order_material", + "pp_production_batch_ledger_txn", + "pp_material_issue", + "pp_completion_receipt", + "mrp_material_demand", + "fi_cost_allocation", + "em_maintenance_order", + "wh_stocktake_line", + "wh_inventory_txn", + "so_delivery_item", + "rt_return_order", + "report_audit_logs", + "production_report_items", + "pp_work_order", + "pp_production_batch_ledger", + "mold_lock_feedbacks", + "md_process_route_operation", + "md_bom_item", + "em_maintenance_plan", + "work_session_devices", + "wh_stock_lot", + "wh_stock_balance", + "wh_special_adjustment", + "sys_user_role", + "sys_broadcast_message", + "sys_ai_assistant_config", + "so_sales_order_item", + "so_delivery", + "production_reports", + "po_receipt", + "po_purchase_return", + "po_purchase_order_sales_order", + "notice_points", + "md_product", + "md_process_route", + "md_material", + "md_bom", + "fi_statement_snapshot", + "fi_overhead_entry", + "em_equipment", + "work_sessions", + "wh_stocktake_warehouse", + "wh_location", + "sys_user", + "sys_role_permission", + "sys_org_manager_binding", + "sys_org_employee_binding", + "so_sales_order", + "po_purchase_order", + "person_roles", + "person_attendance_points", + "notices", + "md_work_center", + "md_item", + "device_qrcodes", + "device_qrcode_batch_tasks", + "work_schedules", + "wh_warehouse", + "wh_stocktake", + "sys_system_config", + "sys_role", + "sys_permission", + "reconciliation_ledger_entries", + "products", + "personnel", + "md_unit", + "md_supplier", + "md_process", + "md_item_category", + "md_customer", + "hr_employee", + "sys_department", + "fi_accounting_period", + "equipment", + "document_archives", + "attendance_points", +] + +WAREHOUSE_TYPES = [ + ("RAW", "原材料库", "原材料默认库位"), + ("SEMI", "半成品库", "半成品默认库位"), + ("FINISHED", "成品库", "成品默认库位"), + ("AUX", "辅料库", "辅料默认库位"), + ("SCRAP", "废料库", "废料默认库位"), + ("RETURN", "退货库", "退货默认库位"), +] + + +@dataclass(frozen=True) +class SystemInitializeOptions: + mode: str + company_name: str + admin_name: str + admin_phone: str + admin_password: str + smart_operation_report_enabled: bool = True + confirm_reset: bool = False + delete_files: bool = False + allow_any_database: bool = False + + +@dataclass(frozen=True) +class SystemInitializeResult: + mode: str + database: str = "" + backup_path: str | None = None + summary_path: str | None = None + counts_before: dict[str, int] = field(default_factory=dict) + counts_after: dict[str, int] = field(default_factory=dict) + seeded: dict[str, int | str | bool] = field(default_factory=dict) + + +def _now() -> datetime: + return datetime.now().replace(microsecond=0) + + +def _flatten_menu_permissions(nodes: list[tuple[str, str, list]]) -> list[tuple[str, str]]: + result: list[tuple[str, str]] = [] + for code, name, children in nodes: + result.append((code, name)) + result.extend(_flatten_menu_permissions(children)) + return result + + +def _is_sqlite(db: Session) -> bool: + bind = db.get_bind() + return bool(bind and bind.dialect.name == "sqlite") + + +def _is_mysql(db: Session) -> bool: + bind = db.get_bind() + return bool(bind and bind.dialect.name in {"mysql", "mariadb"}) + + +def _quote_table_name(table_name: str) -> str: + if not table_name.replace("_", "").isalnum(): + raise ValueError(f"Unsafe table name: {table_name}") + return f"`{table_name}`" + + +def _existing_tables(db: Session, table_names: Iterable[str]) -> set[str]: + connection = db.connection() + if connection is None: + return set() + existing = set(inspect(connection).get_table_names()) + return {table_name for table_name in table_names if table_name in existing} + + +def _count_table(db: Session, table_name: str) -> int: + return int(db.execute(text(f"SELECT COUNT(*) FROM {_quote_table_name(table_name)}")).scalar_one() or 0) + + +def count_existing_tables(db: Session, table_names: Iterable[str]) -> dict[str, int]: + db.flush() + existing = _existing_tables(db, table_names) + return {table_name: _count_table(db, table_name) for table_name in table_names if table_name in existing} + + +def _sqlite_has_sequence_table(db: Session) -> bool: + return bool( + db.execute( + text( + """ + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name = 'sqlite_sequence' + """ + ) + ).first() + ) + + +def _reset_sqlite_sequences(db: Session, table_names: Iterable[str]) -> None: + if not _sqlite_has_sequence_table(db): + return + for table_name in table_names: + db.execute(text("DELETE FROM sqlite_sequence WHERE name = :table_name"), {"table_name": table_name}) + + +def _nullify_pre_delete_foreign_keys(db: Session, table_names: Iterable[str], existing_tables: set[str]) -> None: + for table_name in table_names: + columns = PRE_DELETE_NULLABLE_FKS.get(table_name, []) + if not columns or table_name not in existing_tables: + continue + assignments = ", ".join(f"{column_name} = NULL" for column_name in columns) + db.execute(text(f"UPDATE {_quote_table_name(table_name)} SET {assignments}")) + + +def _clear_tables(db: Session, table_names: Iterable[str]) -> None: + db.flush() + existing = _existing_tables(db, table_names) + tables_to_clear = [table_name for table_name in table_names if table_name in existing] + if not tables_to_clear: + return + + if _is_mysql(db): + db.execute(text("SET FOREIGN_KEY_CHECKS = 0")) + + try: + _nullify_pre_delete_foreign_keys(db, tables_to_clear, existing) + for table_name in tables_to_clear: + db.execute(text(f"DELETE FROM {_quote_table_name(table_name)}")) + if _is_sqlite(db): + _reset_sqlite_sequences(db, tables_to_clear) + finally: + if _is_mysql(db): + db.execute(text("SET FOREIGN_KEY_CHECKS = 1")) + + +def _summary_table_names() -> list[str]: + return list(RESET_TABLES_IN_DELETE_ORDER) + + +def _add_seed_row(db: Session, row: object) -> object: + """SQLite does not autoincrement BIGINT PKs; MySQL still uses native AUTO_INCREMENT.""" + if _is_sqlite(db) and getattr(row, "id", None) is None and hasattr(row, "__table__"): + primary_keys = list(row.__table__.primary_key.columns) + if len(primary_keys) == 1 and primary_keys[0].name == "id": + database_max = int(db.execute(select(func.coalesce(func.max(primary_keys[0]), 0)).select_from(row.__table__)).scalar_one() or 0) + pending_max = max( + ( + int(pending_id) + for pending in db.new + if getattr(pending, "__table__", None) is row.__table__ + and (pending_id := getattr(pending, "id", None)) is not None + ), + default=0, + ) + setattr(row, "id", max(database_max, pending_max) + 1) + db.add(row) + return row + + +def _upsert_system_config(db: Session, *, code: str, name: str, value: str, remark: str) -> None: + now = _now() + row = db.scalar(select(SystemConfig).where(SystemConfig.config_code == code)) + if row is None: + _add_seed_row( + db, + SystemConfig( + config_code=code, + config_name=name, + config_value=value, + remark=remark, + status="ACTIVE", + created_by=None, + updated_by=None, + created_at=now, + updated_at=now, + ), + ) + return + row.config_name = name + row.config_value = value + row.remark = remark + row.status = "ACTIVE" + row.updated_at = now + + +def _upsert_role(db: Session, role_code: str, role_name: str) -> Role: + now = _now() + row = db.scalar(select(Role).where(Role.role_code == role_code)) + if row is None: + row = Role( + role_code=role_code, + role_name=role_name, + role_scope="SYSTEM", + status="ACTIVE", + remark="系统初始化内置角色", + created_at=now, + updated_at=now, + ) + _add_seed_row(db, row) + db.flush() + return row + row.role_name = role_name + row.role_scope = "SYSTEM" + row.status = "ACTIVE" + row.remark = "系统初始化内置角色" + row.updated_at = now + db.flush() + return row + + +def _seed_permissions_and_roles(db: Session) -> dict[str, int]: + now = _now() + role_defs = [ + ("ADMIN", "超级管理员"), + ("PURCHASER", "采购专员"), + ("WAREHOUSE", "仓库管理人员"), + ("SALES", "销售人员"), + ] + roles = [_upsert_role(db, code, name) for code, name in role_defs] + permissions: list[Permission] = [] + for code, name in _flatten_menu_permissions(MENU_PERMISSION_TREE): + permission = db.scalar(select(Permission).where(Permission.permission_code == code)) + if permission is None: + permission = Permission( + permission_code=code, + permission_name=name, + module_code=code.replace("MENU_", ""), + action_code="VIEW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + _add_seed_row(db, permission) + db.flush() + else: + permission.permission_name = name + permission.module_code = code.replace("MENU_", "") + permission.action_code = "VIEW" + permission.status = "ACTIVE" + permission.updated_at = now + db.flush() + permissions.append(permission) + + admin_role = roles[0] + for permission in permissions: + existing = db.scalar( + select(RolePermission).where( + RolePermission.role_id == admin_role.id, + RolePermission.permission_id == permission.id, + ) + ) + if existing is None: + _add_seed_row(db, RolePermission(role_id=admin_role.id, permission_id=permission.id, created_at=now)) + return {"role_count": len(role_defs), "permission_count": len(permissions)} + + +def _seed_system_configs(db: Session, options: SystemInitializeOptions) -> None: + _upsert_system_config( + db, + code="RAW_MATERIAL_LOT_PREFIX", + name="原材料库存批次前缀", + value="YL", + remark="系统初始化默认值", + ) + _upsert_system_config( + db, + code="SMART_OPERATION_REPORT_ENABLED", + name="对接智能报工小程序", + value="开启" if options.smart_operation_report_enabled else "关闭", + remark="系统初始化默认值", + ) + + +def _seed_units_and_categories(db: Session) -> dict[str, int]: + now = _now() + unit_defs = [ + ("KG", "kg", 3), + ("PCS", "件", 0), + ("SET", "套", 0), + ] + for code, name, precision in unit_defs: + unit = db.scalar(select(Unit).where(Unit.unit_code == code)) + if unit is None: + _add_seed_row(db, Unit(unit_code=code, unit_name=name, precision_digits=precision, created_at=now, updated_at=now)) + else: + unit.unit_name = name + unit.precision_digits = precision + unit.updated_at = now + + category_defs = [ + ("RAW", "原材料", "RAW_MATERIAL"), + ("SEMI", "半成品", "SEMI_FINISHED"), + ("FINISHED", "成品", "FINISHED_GOOD"), + ("AUX", "辅料", "AUXILIARY"), + ("SCRAP", "废料", "SCRAP"), + ] + for code, name, item_type in category_defs: + category = db.scalar(select(ItemCategory).where(ItemCategory.category_code == code)) + if category is None: + _add_seed_row( + db, + ItemCategory( + category_code=code, + category_name=name, + parent_id=None, + item_type=item_type, + sort_no=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ), + ) + else: + category.category_name = name + category.item_type = item_type + category.status = "ACTIVE" + category.updated_at = now + return {"unit_count": len(unit_defs), "category_count": len(category_defs)} + + +def _seed_warehouses(db: Session) -> dict[str, int]: + now = _now() + for warehouse_type, warehouse_name, location_name in WAREHOUSE_TYPES: + warehouse = db.scalar(select(Warehouse).where(Warehouse.warehouse_type == warehouse_type)) + if warehouse is None: + warehouse = Warehouse( + warehouse_code=f"WH_{warehouse_type}", + warehouse_name=warehouse_name, + warehouse_type=warehouse_type, + manager_employee_id=None, + status="ACTIVE", + remark="系统初始化默认仓库", + created_at=now, + updated_at=now, + ) + _add_seed_row(db, warehouse) + db.flush() + else: + warehouse.warehouse_code = f"WH_{warehouse_type}" + warehouse.warehouse_name = warehouse_name + warehouse.status = "ACTIVE" + warehouse.updated_at = now + db.flush() + + location = db.scalar( + select(WarehouseLocation).where( + WarehouseLocation.warehouse_id == warehouse.id, + WarehouseLocation.location_code == f"LOC_{warehouse_type}", + ) + ) + if location is None: + _add_seed_row( + db, + WarehouseLocation( + warehouse_id=warehouse.id, + location_code=f"LOC_{warehouse_type}", + location_name=location_name, + zone_name=None, + is_default=1, + is_locked=0, + status="ACTIVE", + remark="系统初始化默认库位", + created_at=now, + updated_at=now, + ), + ) + else: + location.location_name = location_name + location.is_default = 1 + location.is_locked = 0 + location.status = "ACTIVE" + location.updated_at = now + return {"warehouse_count": len(WAREHOUSE_TYPES), "location_count": len(WAREHOUSE_TYPES)} + + +def _seed_admin_user(db: Session, options: SystemInitializeOptions) -> dict[str, int | str]: + now = _now() + root = db.scalar(select(Department).where(Department.dept_code == "ORG_ROOT")) + if root is None: + root = Department( + dept_code="ORG_ROOT", + dept_name=options.company_name, + parent_id=None, + org_node_type="COMPANY", + dept_type="ADMIN", + manager_name=None, + manager_employee_id=None, + status="ACTIVE", + sort_no=0, + remark="系统初始化根组织", + created_at=now, + updated_at=now, + ) + _add_seed_row(db, root) + db.flush() + else: + root.dept_name = options.company_name + root.org_node_type = "COMPANY" + root.dept_type = "ADMIN" + root.status = "ACTIVE" + root.updated_at = now + db.flush() + + employee = db.scalar(select(Employee).where(Employee.employee_code == "EMP_ADMIN")) + if employee is None: + employee = Employee( + employee_code="EMP_ADMIN", + employee_name=options.admin_name, + dept_id=root.id, + mobile=options.admin_phone, + gender=None, + hire_date=None, + job_title="超级管理员", + shift_code=None, + manager_employee_id=None, + is_operator=0, + is_workshop_staff=0, + status="ACTIVE", + remark="系统初始化超级管理员", + created_at=now, + updated_at=now, + ) + _add_seed_row(db, employee) + db.flush() + else: + employee.employee_name = options.admin_name + employee.dept_id = root.id + employee.mobile = options.admin_phone + employee.status = "ACTIVE" + employee.updated_at = now + db.flush() + + user = db.scalar(select(User).where(User.username == options.admin_phone)) + if user is None: + user = User( + username=options.admin_phone, + password_hash=hash_password(options.admin_password), + employee_id=employee.id, + dept_id=root.id, + nickname=options.admin_name, + email=None, + is_super_admin=1, + last_login_at=None, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + _add_seed_row(db, user) + db.flush() + else: + user.password_hash = hash_password(options.admin_password) + user.employee_id = employee.id + user.dept_id = root.id + user.nickname = options.admin_name + user.is_super_admin = 1 + user.status = "ACTIVE" + user.updated_at = now + db.flush() + + admin_role = db.scalar(select(Role).where(Role.role_code == "ADMIN")) + if admin_role is not None: + existing = db.scalar(select(UserRole).where(UserRole.user_id == user.id, UserRole.role_id == admin_role.id)) + if existing is None: + _add_seed_row(db, UserRole(user_id=user.id, role_id=admin_role.id, created_at=now)) + return {"admin_user_id": user.id, "admin_username": user.username} + + +def _seed_ai_assistant_config(db: Session, options: SystemInitializeOptions, admin_user_id: int | None) -> dict[str, int | str]: + now = _now() + row = db.scalar(select(AiAssistantConfig).order_by(AiAssistantConfig.id.asc())) + assistant_name = f"{options.company_name}工艺助手" + if row is None: + row = AiAssistantConfig( + assistant_name=assistant_name, + provider_name=None, + api_base_url=None, + api_key=None, + model_name=None, + temperature=0.3, + system_prompt=None, + enabled=0, + created_by=admin_user_id, + updated_by=admin_user_id, + created_at=now, + updated_at=now, + ) + _add_seed_row(db, row) + db.flush() + else: + row.assistant_name = assistant_name + row.updated_by = admin_user_id + row.updated_at = now + db.flush() + return {"ai_assistant_config_count": 1, "ai_assistant_name": assistant_name} + + +def _seed_miniapp_defaults(db: Session, options: SystemInitializeOptions) -> dict[str, int]: + now = _now() + point = db.execute(text("SELECT name FROM attendance_points WHERE name = :name"), {"name": options.company_name}).first() + if point is None: + db.execute( + text( + """ + INSERT INTO attendance_points + (name, latitude, longitude, radius_meters, remark, is_active, created_at, updated_at) + VALUES + (:name, NULL, NULL, 500, '系统初始化默认考勤点', 1, :now, :now) + """ + ), + {"name": options.company_name, "now": now}, + ) + else: + db.execute( + text( + """ + UPDATE attendance_points + SET remark = '系统初始化默认考勤点', is_active = 1, updated_at = :now + WHERE name = :name + """ + ), + {"name": options.company_name, "now": now}, + ) + + person = db.execute(text("SELECT phone FROM personnel WHERE phone = :phone"), {"phone": options.admin_phone}).first() + if person is None: + db.execute( + text( + """ + INSERT INTO personnel (phone, name, is_temporary, temporary_expires_at, created_at, updated_at) + VALUES (:phone, :name, 0, NULL, :now, :now) + """ + ), + {"phone": options.admin_phone, "name": options.admin_name, "now": now}, + ) + else: + db.execute( + text( + """ + UPDATE personnel + SET name = :name, is_temporary = 0, temporary_expires_at = NULL, updated_at = :now + WHERE phone = :phone + """ + ), + {"phone": options.admin_phone, "name": options.admin_name, "now": now}, + ) + + role = db.execute( + text("SELECT phone FROM person_roles WHERE phone = :phone AND role = 'admin'"), + {"phone": options.admin_phone}, + ).first() + if role is None: + db.execute( + text("INSERT INTO person_roles (phone, role, created_at) VALUES (:phone, 'admin', :now)"), + {"phone": options.admin_phone, "now": now}, + ) + + binding = db.execute( + text( + """ + SELECT phone + FROM person_attendance_points + WHERE phone = :phone AND attendance_point_name = :point + """ + ), + {"phone": options.admin_phone, "point": options.company_name}, + ).first() + if binding is None: + db.execute( + text( + """ + INSERT INTO person_attendance_points (phone, attendance_point_name, created_at) + VALUES (:phone, :point, :now) + """ + ), + {"phone": options.admin_phone, "point": options.company_name, "now": now}, + ) + return {"miniapp_attendance_point_count": 1, "miniapp_admin_count": 1} + + +def seed_system_skeleton(db: Session, options: SystemInitializeOptions) -> SystemInitializeResult: + permission_summary = _seed_permissions_and_roles(db) + _seed_system_configs(db, options) + unit_summary = _seed_units_and_categories(db) + warehouse_summary = _seed_warehouses(db) + admin_summary = _seed_admin_user(db, options) + ai_summary = _seed_ai_assistant_config(db, options, int(admin_summary["admin_user_id"]) if admin_summary.get("admin_user_id") else None) + miniapp_summary = _seed_miniapp_defaults(db, options) + + return SystemInitializeResult( + mode=options.mode, + seeded={ + **permission_summary, + **unit_summary, + **warehouse_summary, + **admin_summary, + **ai_summary, + **miniapp_summary, + "smart_operation_report_enabled": options.smart_operation_report_enabled, + }, + ) + + +def reset_existing_database(db: Session, options: SystemInitializeOptions) -> SystemInitializeResult: + if not options.confirm_reset: + raise ValueError("Refusing to reset database without --confirm-reset") + + table_names = _summary_table_names() + counts_before = count_existing_tables(db, table_names) + _clear_tables(db, table_names) + seeded_result = seed_system_skeleton(db, options) + db.flush() + counts_after = count_existing_tables(db, table_names) + + return SystemInitializeResult( + mode="reset", + counts_before=counts_before, + counts_after=counts_after, + seeded=seeded_result.seeded, + ) + + +def create_all_schema_tables(engine: Engine) -> None: + for module_info in pkgutil.iter_modules(models_package.__path__): + if module_info.name.startswith("_"): + continue + importlib.import_module(f"{models_package.__name__}.{module_info.name}") + Base.metadata.create_all(engine) + + +def fresh_initialize_database(engine: Engine, options: SystemInitializeOptions) -> SystemInitializeResult: + create_all_schema_tables(engine) + fresh_options = SystemInitializeOptions( + mode="fresh", + company_name=options.company_name, + admin_name=options.admin_name, + admin_phone=options.admin_phone, + admin_password=options.admin_password, + smart_operation_report_enabled=options.smart_operation_report_enabled, + confirm_reset=options.confirm_reset, + delete_files=options.delete_files, + allow_any_database=options.allow_any_database, + ) + with Session(engine, future=True) as db: + seeded_result = seed_system_skeleton(db, fresh_options) + db.flush() + counts_after = count_existing_tables(db, _summary_table_names()) + db.commit() + + return SystemInitializeResult( + mode="fresh", + counts_after=counts_after, + seeded=seeded_result.seeded, + ) + + +def _mysql_url_from_settings(settings: object, *, include_database: bool) -> URL: + dsn = getattr(settings, "mysql_dsn", None) + if isinstance(dsn, URL): + return dsn.set(database=getattr(settings, "mysql_database", None) if include_database else None) + return URL.create( + "mysql+pymysql", + username=getattr(settings, "mysql_user"), + password=getattr(settings, "mysql_password"), + host=getattr(settings, "mysql_host"), + port=getattr(settings, "mysql_port"), + database=getattr(settings, "mysql_database") if include_database else None, + ) + + +def create_database_if_missing(settings: object) -> str: + database = str(getattr(settings, "mysql_database")) + server_url = _mysql_url_from_settings(settings, include_database=False) + engine = create_engine(server_url, isolation_level="AUTOCOMMIT", future=True) + try: + with engine.connect() as conn: + conn.execute( + text( + f"CREATE DATABASE IF NOT EXISTS {_quote_table_name(database)} " + "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ) + ) + finally: + engine.dispose() + return database + + +def write_summary_report(output_dir: str | Path, result: SystemInitializeResult) -> str: + output = Path(output_dir) + output.mkdir(parents=True, exist_ok=True) + created_at = _now().isoformat() + summary_path = output / f"system_init_summary_{created_at.replace(':', '').replace('-', '')}.json" + payload = { + "mode": result.mode, + "database": result.database, + "backup_path": result.backup_path, + "summary_path": str(summary_path), + "counts_before": result.counts_before, + "counts_after": result.counts_after, + "seeded": result.seeded, + "created_at": created_at, + } + summary_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") + return str(summary_path) + + +def default_output_dir() -> Path: + return Path(__file__).resolve().parents[3] / "outputs" / "cleanup_backups" + + +def backup_mysql_database(settings: object, output_dir: str | Path) -> str: + output = Path(output_dir) + output.mkdir(parents=True, exist_ok=True) + timestamp = _now().strftime("%Y%m%d_%H%M%S") + backup_path = output / f"system_init_backup_{timestamp}.sql" + database = str(getattr(settings, "mysql_database")) + password = str(getattr(settings, "mysql_password", "") or "") + with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as defaults_file: + defaults_path = Path(defaults_file.name) + defaults_file.write("[client]\n") + defaults_file.write(f"host={getattr(settings, 'mysql_host')}\n") + defaults_file.write(f"port={getattr(settings, 'mysql_port')}\n") + defaults_file.write(f"user={getattr(settings, 'mysql_user')}\n") + if password: + defaults_file.write(f"password={password}\n") + defaults_path.chmod(0o600) + command = [ + "mysqldump", + f"--defaults-extra-file={defaults_path}", + f"--result-file={backup_path}", + "--single-transaction", + "--routines", + "--triggers", + database, + ] + try: + subprocess.run(command, check=True) + finally: + defaults_path.unlink(missing_ok=True) + return str(backup_path) + + +def delete_managed_files(directories: Iterable[Path]) -> dict[str, int]: + def count_tree(path: Path) -> tuple[int, int]: + if path.is_file() or path.is_symlink(): + return 1, 0 + file_count = 0 + dir_count = 1 + for nested in path.iterdir(): + nested_file_count, nested_dir_count = count_tree(nested) + file_count += nested_file_count + dir_count += nested_dir_count + return file_count, dir_count + + deleted_file_count = 0 + deleted_dir_count = 0 + for directory in directories: + if not directory.exists() or not directory.is_dir(): + continue + for child in directory.iterdir(): + if child.is_file() or child.is_symlink(): + child.unlink() + deleted_file_count += 1 + elif child.is_dir(): + nested_file_count, nested_dir_count = count_tree(child) + shutil.rmtree(child) + deleted_file_count += nested_file_count + deleted_dir_count += nested_dir_count + return {"deleted_file_count": deleted_file_count, "deleted_dir_count": deleted_dir_count} + + +def reset_existing_database_with_backup( + engine: Engine, + settings: object, + options: SystemInitializeOptions, + output_dir: str | Path | None = None, +) -> SystemInitializeResult: + output = Path(output_dir) if output_dir is not None else default_output_dir() + backup_path = backup_mysql_database(settings, output) + database = str(getattr(settings, "mysql_database", "")) + + with Session(engine, future=True) as db: + reset_result = reset_existing_database(db, options) + db.commit() + + result = SystemInitializeResult( + mode=reset_result.mode, + database=database, + backup_path=backup_path, + counts_before=reset_result.counts_before, + counts_after=reset_result.counts_after, + seeded=reset_result.seeded, + ) + if options.delete_files: + root = Path(__file__).resolve().parents[3] + file_cleanup = delete_managed_files( + [ + root / "outputs" / "document_archives", + root / "outputs" / "document_archive_batches", + root / "backend" / "uploads" / "logistics", + ] + ) + result = SystemInitializeResult( + mode=result.mode, + database=result.database, + backup_path=result.backup_path, + counts_before=result.counts_before, + counts_after=result.counts_after, + seeded={**result.seeded, **file_cleanup}, + ) + summary_path = write_summary_report(output, result) + return SystemInitializeResult( + mode=result.mode, + database=result.database, + backup_path=result.backup_path, + summary_path=summary_path, + counts_before=result.counts_before, + counts_after=result.counts_after, + seeded=result.seeded, + ) diff --git a/backend/scripts/__init__.py b/backend/scripts/__init__.py new file mode 100644 index 0000000..f1d2da4 --- /dev/null +++ b/backend/scripts/__init__.py @@ -0,0 +1 @@ +"""Operational scripts for ForgeFlow ERP backend.""" diff --git a/backend/scripts/system_initialize.py b/backend/scripts/system_initialize.py new file mode 100644 index 0000000..a2e86ee --- /dev/null +++ b/backend/scripts/system_initialize.py @@ -0,0 +1,89 @@ +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_arguments(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="Enable or disable 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 database if needed, create schema, and seed a clean skeleton.") + add_common_arguments(fresh) + + reset = subparsers.add_parser("reset", help="Back up and reset an existing database into a clean skeleton.") + add_common_arguments(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 _print_result(settings: object, result: object, options: SystemInitializeOptions) -> None: + print(f"mode={options.mode}") + print(f"database={settings.mysql_host}:{settings.mysql_port}/{settings.mysql_database}") + 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', '')}") + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + settings = get_settings() + options = _options_from_args(args) + engine = create_engine(settings.mysql_dsn, pool_pre_ping=True, future=True) + + if options.mode == "fresh": + create_database_if_missing(settings) + result = fresh_initialize_database(engine, options) + else: + result = reset_existing_database_with_backup(engine, settings, options) + + _print_result(settings, result, options) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/test_system_initializer_cli.py b/backend/tests/test_system_initializer_cli.py new file mode 100644 index 0000000..6262615 --- /dev/null +++ b/backend/tests/test_system_initializer_cli.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import io +import unittest +from types import SimpleNamespace +from unittest.mock import patch + + +class SystemInitializerCliTest(unittest.TestCase): + def test_parse_fresh_arguments(self) -> None: + from scripts.system_initialize import parse_args + + args = parse_args( + [ + "fresh", + "--company-name", + "百华", + "--admin-name", + "超级管理员", + "--admin-phone", + "13800000000", + "--admin-password", + "secret123", + "--smart-operation-report", + "off", + "--delete-files", + "--allow-any-database", + ] + ) + + self.assertEqual(args.mode, "fresh") + self.assertEqual(args.company_name, "百华") + self.assertEqual(args.admin_name, "超级管理员") + self.assertEqual(args.admin_phone, "13800000000") + self.assertEqual(args.admin_password, "secret123") + self.assertEqual(args.smart_operation_report, "off") + self.assertTrue(args.delete_files) + self.assertTrue(args.allow_any_database) + self.assertFalse(hasattr(args, "confirm_reset")) + + 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") + + def test_main_fresh_creates_database_then_initializes_without_real_db(self) -> None: + from scripts import system_initialize + + settings = SimpleNamespace( + mysql_host="127.0.0.1", + mysql_port=3306, + mysql_database="erp_test", + mysql_dsn="mysql+pymysql://root:secret@127.0.0.1:3306/erp_test", + ) + result = SimpleNamespace( + backup_path=None, + summary_path="/tmp/summary.json", + seeded={"admin_username": "13800000000"}, + ) + + with ( + patch.object(system_initialize, "get_settings", return_value=settings), + patch.object(system_initialize, "create_engine", return_value="engine") as create_engine_mock, + patch.object(system_initialize, "create_database_if_missing", return_value="erp_test") as create_db_mock, + patch.object(system_initialize, "fresh_initialize_database", return_value=result) as fresh_mock, + patch.object(system_initialize, "reset_existing_database_with_backup") as reset_mock, + patch("sys.stdout", new_callable=io.StringIO) as stdout, + ): + exit_code = system_initialize.main( + [ + "fresh", + "--company-name", + "百华", + "--admin-name", + "超级管理员", + "--admin-phone", + "13800000000", + "--admin-password", + "secret123", + "--smart-operation-report", + "on", + ] + ) + + self.assertEqual(exit_code, 0) + create_db_mock.assert_called_once_with(settings) + create_engine_mock.assert_called_once_with(settings.mysql_dsn, pool_pre_ping=True, future=True) + fresh_mock.assert_called_once() + reset_mock.assert_not_called() + self.assertIn("mode=fresh", stdout.getvalue()) + self.assertIn("database=127.0.0.1:3306/erp_test", stdout.getvalue()) + self.assertIn("summary_path=/tmp/summary.json", stdout.getvalue()) + self.assertIn("admin_username=13800000000", stdout.getvalue()) + + def test_main_reset_calls_backup_reset_without_swallowing_service_errors(self) -> None: + from scripts import system_initialize + + settings = SimpleNamespace( + mysql_host="127.0.0.1", + mysql_port=3306, + mysql_database="erp_test", + mysql_dsn="mysql+pymysql://root:secret@127.0.0.1:3306/erp_test", + ) + + with ( + patch.object(system_initialize, "get_settings", return_value=settings), + patch.object(system_initialize, "create_engine", return_value="engine"), + patch.object( + system_initialize, + "reset_existing_database_with_backup", + side_effect=ValueError("Refusing to reset database without --confirm-reset"), + ), + ): + with self.assertRaisesRegex(ValueError, "confirm-reset"): + system_initialize.main( + [ + "reset", + "--company-name", + "百华", + "--admin-name", + "超级管理员", + "--admin-phone", + "13800000000", + "--admin-password", + "secret123", + ] + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_system_initializer_models.py b/backend/tests/test_system_initializer_models.py new file mode 100644 index 0000000..e9aa844 --- /dev/null +++ b/backend/tests/test_system_initializer_models.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from pathlib import Path + +from sqlalchemy import create_engine, inspect + +import app.models # noqa: F401 +from app.models.base import Base + + +MINIAPP_TABLES = { + "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", +} + +REQUIRED_ERP_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_material_issue", + "pp_material_issue_item", + "pp_production_batch_ledger", + "pp_production_batch_ledger_txn", + "document_archives", +} + + +def _erp_model_table_names() -> set[str]: + models_dir = Path(__file__).resolve().parents[1] / "app" / "models" + table_names: set[str] = set() + for model_file in models_dir.glob("*.py"): + if model_file.name in {"__init__.py", "base.py", "miniapp.py"}: + continue + for line in model_file.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if stripped.startswith("__tablename__"): + table_names.add(stripped.split("=", 1)[1].strip().strip("\"'")) + return table_names + + +def test_app_models_registers_erp_and_miniapp_tables() -> None: + expected_tables = _erp_model_table_names() | REQUIRED_ERP_TABLES | MINIAPP_TABLES + + missing_tables = expected_tables - set(Base.metadata.tables) + + assert missing_tables == set() + + +def test_reset_table_groups_are_registered_in_metadata() -> None: + from app.services.system_initializer import ACCOUNT_RESET_TABLES, BUSINESS_RESET_TABLES, MINIAPP_RESET_TABLES, SKELETON_TABLES + + expected_tables = set(BUSINESS_RESET_TABLES) | set(MINIAPP_RESET_TABLES) | set(ACCOUNT_RESET_TABLES) | set(SKELETON_TABLES) + + assert expected_tables.issubset(set(Base.metadata.tables)) + + +def test_registered_models_create_all_in_sqlite_memory() -> None: + engine = create_engine("sqlite:///:memory:") + + Base.metadata.create_all(engine) + + created_tables = set(inspect(engine).get_table_names()) + assert (MINIAPP_TABLES | REQUIRED_ERP_TABLES).issubset(created_tables) diff --git a/backend/tests/test_system_initializer_reset.py b/backend/tests/test_system_initializer_reset.py new file mode 100644 index 0000000..21bd3b5 --- /dev/null +++ b/backend/tests/test_system_initializer_reset.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from sqlalchemy import create_engine, inspect, select, text +from sqlalchemy.orm import Session, sessionmaker + +import app.models # noqa: F401 +from app.models.base import Base +from app.models.org import User +from app.services.auth import verify_password + + +def _options(*, confirm_reset: bool = False, mode: str = "reset"): + from app.services.system_initializer import SystemInitializeOptions + + return SystemInitializeOptions( + mode=mode, + company_name="百华", + admin_name="超级管理员", + admin_phone="13800000000", + admin_password="secret123", + confirm_reset=confirm_reset, + ) + + +class SystemInitializerResetTest(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(self.engine) + self.SessionLocal = sessionmaker(bind=self.engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + + def tearDown(self) -> None: + self.db.close() + + def _seed_dirty_data(self) -> None: + self.db.execute( + text( + """ + INSERT INTO sys_department + (id, dept_code, dept_name, org_node_type, dept_type, status, sort_no, created_at, updated_at) + VALUES + (100, 'DIRTY_ROOT', '旧公司', 'COMPANY', 'ADMIN', 'ACTIVE', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """ + ) + ) + self.db.execute( + text( + """ + INSERT INTO hr_employee + (id, employee_code, employee_name, dept_id, mobile, is_operator, is_workshop_staff, status, created_at, updated_at) + VALUES + (100, 'EMP_OLD', '旧管理员', 100, '13900000000', 0, 0, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """ + ) + ) + self.db.execute( + text( + """ + INSERT INTO sys_user + (id, username, password_hash, employee_id, dept_id, nickname, is_super_admin, status, created_at, updated_at) + VALUES + (100, '13900000000', 'old', 100, 100, '旧管理员', 0, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """ + ) + ) + self.db.execute( + text( + """ + INSERT INTO personnel (phone, name, is_temporary, created_at, updated_at) + VALUES ('13900000000', '旧小程序用户', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """ + ) + ) + self.db.execute( + text( + """ + INSERT INTO md_customer + (id, customer_code, customer_name, status, created_at, updated_at) + VALUES + (100, 'C_OLD', '旧客户', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """ + ) + ) + self.db.commit() + + def test_reset_requires_confirm_reset_and_does_not_clear_data(self) -> None: + from app.services.system_initializer import reset_existing_database + + self._seed_dirty_data() + + with self.assertRaisesRegex(ValueError, "confirm-reset"): + reset_existing_database(self.db, _options(confirm_reset=False)) + + self.db.rollback() + self.assertEqual(self.db.execute(text("SELECT COUNT(*) FROM sys_user")).scalar_one(), 1) + self.assertEqual(self.db.execute(text("SELECT COUNT(*) FROM md_customer")).scalar_one(), 1) + + def test_reset_clears_business_account_and_miniapp_data_then_recreates_admin(self) -> None: + from app.services.system_initializer import reset_existing_database + + self._seed_dirty_data() + + result = reset_existing_database(self.db, _options(confirm_reset=True)) + self.db.commit() + + self.assertEqual(result.mode, "reset") + self.assertEqual(result.counts_before["sys_user"], 1) + self.assertEqual(result.counts_before["md_customer"], 1) + self.assertEqual(result.counts_before["personnel"], 1) + self.assertEqual(result.counts_after["md_customer"], 0) + self.assertEqual(result.counts_after["sys_user"], 1) + self.assertEqual(result.counts_after["personnel"], 1) + self.assertEqual(result.seeded["admin_username"], "13800000000") + + self.assertEqual(self.db.execute(text("SELECT COUNT(*) FROM md_customer")).scalar_one(), 0) + self.assertIsNone(self.db.scalar(select(User).where(User.username == "13900000000"))) + admin = self.db.scalar(select(User).where(User.username == "13800000000")) + self.assertIsNotNone(admin) + assert admin is not None + self.assertEqual(admin.is_super_admin, 1) + self.assertTrue(verify_password("secret123", admin.password_hash)) + + def test_reset_keeps_sqlite_foreign_keys_enabled(self) -> None: + from app.services.system_initializer import reset_existing_database + + self.db.execute(text("PRAGMA foreign_keys = ON")) + self._seed_dirty_data() + + reset_existing_database(self.db, _options(confirm_reset=True)) + self.db.commit() + + self.assertEqual(self.db.execute(text("PRAGMA foreign_keys")).scalar_one(), 1) + + def test_write_summary_report_writes_expected_json(self) -> None: + from app.services.system_initializer import SystemInitializeResult, write_summary_report + + result = SystemInitializeResult( + mode="reset", + database="erp_test", + backup_path="/tmp/backup.sql", + counts_before={"sys_user": 2}, + counts_after={"sys_user": 1}, + seeded={"admin_username": "13800000000"}, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + summary_path = write_summary_report(Path(tmpdir), result) + payload = json.loads(Path(summary_path).read_text(encoding="utf-8")) + + self.assertEqual(payload["mode"], "reset") + self.assertEqual(payload["database"], "erp_test") + self.assertEqual(payload["backup_path"], "/tmp/backup.sql") + self.assertEqual(payload["summary_path"], summary_path) + self.assertEqual(payload["counts_before"], {"sys_user": 2}) + self.assertEqual(payload["counts_after"], {"sys_user": 1}) + self.assertEqual(payload["seeded"], {"admin_username": "13800000000"}) + self.assertRegex(payload["created_at"], r"^\d{4}-\d{2}-\d{2}T") + + def test_default_output_dir_uses_cleanup_backups_folder(self) -> None: + from app.services.system_initializer import default_output_dir + + self.assertEqual(default_output_dir().name, "cleanup_backups") + self.assertEqual(default_output_dir().parent.name, "outputs") + + def test_backup_mysql_database_uses_defaults_file_without_password_argument(self) -> None: + from app.services.system_initializer import backup_mysql_database + + class Settings: + mysql_host = "127.0.0.1" + mysql_port = 3306 + mysql_user = "root" + mysql_password = "secret" + mysql_database = "jiaheng_erp" + + commands: list[list[str]] = [] + + def fake_run(command: list[str], check: bool) -> None: + commands.append(command) + defaults_arg = next(part for part in command if part.startswith("--defaults-extra-file=")) + defaults_path = Path(defaults_arg.split("=", 1)[1]) + self.assertTrue(defaults_path.exists()) + self.assertIn("password=secret", defaults_path.read_text(encoding="utf-8")) + self.assertEqual(defaults_path.stat().st_mode & 0o777, 0o600) + + with tempfile.TemporaryDirectory() as tmpdir: + with patch("app.services.system_initializer.subprocess.run", side_effect=fake_run): + backup_path = backup_mysql_database(Settings(), tmpdir) + + self.assertTrue(backup_path.endswith(".sql")) + self.assertEqual(len(commands), 1) + self.assertFalse(any("secret" in part for part in commands[0])) + defaults_arg = next(part for part in commands[0] if part.startswith("--defaults-extra-file=")) + self.assertFalse(Path(defaults_arg.split("=", 1)[1]).exists()) + + +class SystemInitializerFreshTest(unittest.TestCase): + def test_fresh_create_all_builds_key_tables_and_seeds_skeleton(self) -> None: + from app.services.system_initializer import fresh_initialize_database + + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + + result = fresh_initialize_database(engine, _options(mode="fresh")) + + created_tables = set(inspect(engine).get_table_names()) + self.assertIn("sys_user", created_tables) + self.assertIn("md_customer", created_tables) + self.assertIn("attendance_points", created_tables) + self.assertEqual(result.mode, "fresh") + self.assertEqual(result.counts_after["sys_user"], 1) + self.assertEqual(result.counts_after["wh_warehouse"], 6) + self.assertEqual(result.seeded["admin_username"], "13800000000") + + with Session(engine, future=True) as db: + self.assertIsNotNone(db.scalar(select(User).where(User.username == "13800000000"))) + + +class SystemInitializerFileCleanupTest(unittest.TestCase): + def test_delete_managed_files_removes_only_children_and_keeps_directories(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" + nested_dir = archive_dir / "batch" + archive_dir.mkdir(parents=True) + photo_dir.mkdir(parents=True) + nested_dir.mkdir() + (archive_dir / "a.pdf").write_text("pdf", encoding="utf-8") + (nested_dir / "nested.pdf").write_text("nested", 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"], 3) + self.assertEqual(summary["deleted_dir_count"], 1) + self.assertTrue(archive_dir.exists()) + self.assertTrue(photo_dir.exists()) + self.assertEqual(list(archive_dir.iterdir()), []) + self.assertEqual(list(photo_dir.iterdir()), []) + + def test_reset_with_backup_skips_file_cleanup_by_default(self) -> None: + from app.services import system_initializer + from app.services.system_initializer import SystemInitializeResult, reset_existing_database_with_backup + + class Settings: + mysql_database = "erp_test" + + reset_result = SystemInitializeResult( + mode="reset", + counts_before={"sys_user": 2}, + counts_after={"sys_user": 1}, + seeded={"admin_username": "13800000000"}, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + with ( + patch.object(system_initializer, "backup_mysql_database", return_value="/tmp/backup.sql"), + patch.object(system_initializer, "reset_existing_database", return_value=reset_result), + patch.object(system_initializer, "delete_managed_files") as delete_mock, + patch.object(system_initializer, "Session") as session_mock, + ): + session_mock.return_value.__enter__.return_value = MagicMock() + result = reset_existing_database_with_backup( + engine=object(), + settings=Settings(), + options=_options(confirm_reset=True), + output_dir=tmpdir, + ) + + delete_mock.assert_not_called() + self.assertNotIn("deleted_file_count", result.seeded) + + def test_reset_with_backup_includes_file_cleanup_when_requested(self) -> None: + from app.services import system_initializer + from app.services.system_initializer import SystemInitializeResult, SystemInitializeOptions, reset_existing_database_with_backup + + class Settings: + mysql_database = "erp_test" + + reset_result = SystemInitializeResult( + mode="reset", + counts_before={"sys_user": 2}, + counts_after={"sys_user": 1}, + seeded={"admin_username": "13800000000"}, + ) + options = SystemInitializeOptions( + mode="reset", + company_name="百华", + admin_name="超级管理员", + admin_phone="13800000000", + admin_password="secret123", + confirm_reset=True, + delete_files=True, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + with ( + patch.object(system_initializer, "backup_mysql_database", return_value="/tmp/backup.sql"), + patch.object(system_initializer, "reset_existing_database", return_value=reset_result), + patch.object( + system_initializer, + "delete_managed_files", + return_value={"deleted_file_count": 3, "deleted_dir_count": 2}, + ) as delete_mock, + patch.object(system_initializer, "Session") as session_mock, + ): + session_mock.return_value.__enter__.return_value = MagicMock() + result = reset_existing_database_with_backup( + engine=object(), + settings=Settings(), + options=options, + output_dir=tmpdir, + ) + + delete_mock.assert_called_once() + self.assertEqual(result.seeded["deleted_file_count"], 3) + self.assertEqual(result.seeded["deleted_dir_count"], 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_system_initializer_seed.py b/backend/tests/test_system_initializer_seed.py new file mode 100644 index 0000000..8e6ada3 --- /dev/null +++ b/backend/tests/test_system_initializer_seed.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import unittest + +from sqlalchemy import create_engine, select, text +from sqlalchemy.orm import Session, sessionmaker + + +import app.models # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Warehouse # noqa: E402 +from app.models.miniapp import MiniAppPersonAttendancePoint, MiniAppPersonnel, MiniAppPersonRole # noqa: E402 +from app.models.operations import WarehouseLocation # noqa: E402 +from app.models.org import AiAssistantConfig, Department, Employee, Permission, Role, RolePermission, SystemConfig, User, UserRole # noqa: E402 +from app.services.auth import verify_password # noqa: E402 +from app.services.system_permissions import MENU_PERMISSION_TREE # noqa: E402 + + +class SystemInitializerSeedTest(unittest.TestCase): + def test_table_groups_have_no_duplicates_and_include_expected_tables(self) -> None: + from app.services.system_initializer import ( + ACCOUNT_RESET_TABLES, + BUSINESS_RESET_TABLES, + MINIAPP_RESET_TABLES, + RESET_TABLES_IN_DELETE_ORDER, + SKELETON_TABLES, + ) + + all_tables = BUSINESS_RESET_TABLES + MINIAPP_RESET_TABLES + ACCOUNT_RESET_TABLES + SKELETON_TABLES + reset_tables = BUSINESS_RESET_TABLES + MINIAPP_RESET_TABLES + ACCOUNT_RESET_TABLES + + self.assertEqual(len(all_tables), len(set(all_tables))) + self.assertEqual(set(reset_tables).issubset(set(RESET_TABLES_IN_DELETE_ORDER)), True) + self.assertEqual(len(RESET_TABLES_IN_DELETE_ORDER), len(set(RESET_TABLES_IN_DELETE_ORDER))) + 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_user", ACCOUNT_RESET_TABLES) + self.assertIn("sys_permission", SKELETON_TABLES) + self.assertIn("wh_warehouse", SKELETON_TABLES) + + +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_required_defaults(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) + assert admin_user is not None + 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.assertIsNotNone(admin_employee) + assert admin_employee is not None + self.assertEqual(admin_employee.employee_name, "超级管理员") + + root = self.db.scalar(select(Department).where(Department.dept_code == "ORG_ROOT")) + self.assertIsNotNone(root) + assert root is not None + self.assertEqual(root.dept_name, "百华") + + expected_permission_codes = set() + + def walk(nodes: list[tuple[str, str, list]]) -> None: + for code, _name, children in nodes: + expected_permission_codes.add(code) + walk(children) + + walk(MENU_PERMISSION_TREE) + actual_permission_codes = set(self.db.scalars(select(Permission.permission_code)).all()) + self.assertEqual(expected_permission_codes, actual_permission_codes) + + admin_role = self.db.scalar(select(Role).where(Role.role_code == "ADMIN")) + self.assertIsNotNone(admin_role) + assert admin_role is not None + self.assertEqual( + self.db.query(RolePermission).filter(RolePermission.role_id == admin_role.id).count(), + len(expected_permission_codes), + ) + role_names = {role.role_code: role.role_name for role in self.db.scalars(select(Role)).all()} + self.assertEqual( + role_names, + { + "ADMIN": "超级管理员", + "PURCHASER": "采购专员", + "WAREHOUSE": "仓库管理人员", + "SALES": "销售人员", + }, + ) + self.assertEqual(self.db.query(UserRole).count(), 1) + + self.assertEqual(self.db.query(Warehouse).count(), 6) + self.assertEqual(self.db.query(WarehouseLocation).count(), 6) + self.assertEqual({row.warehouse_type for row in self.db.scalars(select(Warehouse)).all()}, {"RAW", "SEMI", "FINISHED", "AUX", "SCRAP", "RETURN"}) + self.assertEqual(self.db.query(WarehouseLocation).filter(WarehouseLocation.is_default == 1).count(), 6) + + prefix = self.db.scalar(select(SystemConfig).where(SystemConfig.config_code == "RAW_MATERIAL_LOT_PREFIX")) + self.assertIsNotNone(prefix) + assert prefix is not None + self.assertEqual(prefix.config_value, "YL") + smart = self.db.scalar(select(SystemConfig).where(SystemConfig.config_code == "SMART_OPERATION_REPORT_ENABLED")) + self.assertIsNotNone(smart) + assert smart is not None + self.assertEqual(smart.config_value, "关闭") + ai_config = self.db.scalar(select(AiAssistantConfig)) + self.assertIsNotNone(ai_config) + assert ai_config is not None + self.assertEqual(ai_config.assistant_name, "百华工艺助手") + + units = dict(self.db.execute(text("SELECT unit_code, unit_name FROM md_unit")).all()) + self.assertEqual(units, {"KG": "kg", "PCS": "件", "SET": "套"}) + categories = dict(self.db.execute(text("SELECT category_code, category_name FROM md_item_category")).all()) + self.assertEqual(categories, {"RAW": "原材料", "SEMI": "半成品", "FINISHED": "成品", "AUX": "辅料", "SCRAP": "废料"}) + + miniapp_admin = self.db.get(MiniAppPersonnel, "13800000000") + self.assertIsNotNone(miniapp_admin) + assert miniapp_admin is not None + self.assertEqual(miniapp_admin.name, "超级管理员") + self.assertIsNotNone(self.db.get(MiniAppPersonRole, {"phone": "13800000000", "role": "admin"})) + self.assertIsNotNone( + self.db.get( + MiniAppPersonAttendancePoint, + {"phone": "13800000000", "attendance_point_name": "百华"}, + ) + ) + + self.assertEqual(result.seeded["warehouse_count"], 6) + self.assertEqual(result.seeded["admin_username"], "13800000000") + + def test_seed_system_skeleton_defaults_smart_operation_report_to_enabled(self) -> None: + from app.services.system_initializer import SystemInitializeOptions, seed_system_skeleton + + options = SystemInitializeOptions( + mode="fresh", + company_name="百华", + admin_name="超级管理员", + admin_phone="13800000000", + admin_password="secret123", + ) + + result = seed_system_skeleton(self.db, options) + self.db.commit() + + smart = self.db.scalar(select(SystemConfig).where(SystemConfig.config_code == "SMART_OPERATION_REPORT_ENABLED")) + self.assertIsNotNone(smart) + assert smart is not None + self.assertEqual(smart.config_value, "开启") + self.assertEqual(result.seeded["smart_operation_report_enabled"], True) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/superpowers/plans/2026-06-14-system-initialization-bootstrap.md b/docs/superpowers/plans/2026-06-14-system-initialization-bootstrap.md new file mode 100644 index 0000000..43d1caf --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-system-initialization-bootstrap.md @@ -0,0 +1,2098 @@ +# 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` diff --git a/docs/superpowers/specs/2026-06-14-system-initialization-bootstrap-design.md b/docs/superpowers/specs/2026-06-14-system-initialization-bootstrap-design.md new file mode 100644 index 0000000..029829f --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-system-initialization-bootstrap-design.md @@ -0,0 +1,388 @@ +# 系统标准初始化工具设计 + +## 背景 + +当前系统已经从嘉恒测试环境切到百华客制化分支。数据库中存在大量嘉恒/测试业务数据,百华正式启用前需要初始化为干净系统。 + +这次初始化不能只做一次性清库脚本。后续给其他公司部署时,也需要一套标准工具,能够在全新的 MySQL 数据库上完成建库、建表和系统骨架初始化,同时也能对已经建好表、已经产生测试数据的系统做正式启用前重置。 + +## 目标 + +初始化工具提供两个模式: + +- `fresh`:面向全新 MySQL 或全新 schema,创建 ERP 数据库下所有 ERP 表和智能报工小程序相关表,并写入最小系统骨架。 +- `reset`:面向已有 schema,先备份,再清空业务数据、主数据、人员账号、小程序数据,然后重建最小系统骨架。 + +初始化完成后,系统应满足: + +- 可以用一个超级管理员账号登录 ERP。 +- 系统权限、菜单、角色关系完整。 +- 六大仓库和默认库位存在,后续可直接做出入库。 +- 系统配置存在,例如原材料库存批次前缀、是否对接智能报工小程序。 +- 没有嘉恒或测试业务数据、客户、供应商、物料、产品、库存、订单、报工、发货、财务、单据归档数据残留。 +- 如果后续客户购买小程序,小程序后端所需表也已经创建好。 + +## 非目标 + +- 不做客户真实主数据导入。客户、供应商、物料、产品、BOM、工艺路线后续用现有导入功能导入。 +- 默认不删除上传文件和 PDF 实体文件,只清数据库记录。文件删除作为显式参数单独开启。 +- 不重命名数据库 schema,例如仍可使用当前 `jiaheng_erp` 作为历史技术库名,避免改动部署配置和小程序连接。 +- 不在初始化工具里修改业务逻辑。 + +## 命令形态 + +建议新增入口: + +```bash +python backend/scripts/system_initialize.py fresh \ + --company-name 百华 \ + --admin-name 超级管理员 \ + --admin-phone 13800000000 \ + --admin-password '正式密码' + +python backend/scripts/system_initialize.py reset \ + --company-name 百华 \ + --admin-name 超级管理员 \ + --admin-phone 13800000000 \ + --admin-password '正式密码' \ + --confirm-reset +``` + +脚本读取 `backend/.env` 中的 MySQL 连接信息: + +- `MYSQL_HOST` +- `MYSQL_PORT` +- `MYSQL_DATABASE` +- `MYSQL_USER` +- `MYSQL_PASSWORD` + +## 模式一:fresh + +`fresh` 用于全新部署,是后续交付其他公司最常用的路径。 + +流程: + +1. 读取 `.env` 中的 MySQL 连接信息。 +2. 连接 MySQL server。 +3. 如果目标 database 不存在,执行 `CREATE DATABASE`。 +4. 切换到目标 database。 +5. 创建 ERP 全量表。 +6. 创建小程序全量表。 +7. 执行所有已纳入标准基线的 patch。 +8. 初始化系统权限、角色、角色权限。 +9. 初始化系统配置。 +10. 初始化单位、物料分类等基础字典。 +11. 初始化六大仓库和默认库位。 +12. 初始化根组织节点、超级管理员人员、超级管理员系统账号、用户角色绑定。 +13. 输出初始化报告。 + +`fresh` 模式不写入任何业务主数据和业务流水。 + +## 模式二:reset + +`reset` 用于已有系统正式启用前清理,例如这次百华切换。 + +流程: + +1. 读取 `.env` 中的 MySQL 连接信息。 +2. 检查必须显式传入 `--confirm-reset`。 +3. 打印目标数据库连接摘要,包括 host、port、database、当前时间。 +4. 统计关键表行数。 +5. 强制导出备份 SQL 到 `outputs/cleanup_backups/system_init_backup_.sql`。 +6. `SET FOREIGN_KEY_CHECKS=0`。 +7. 清空业务流水表。 +8. 清空库存表。 +9. 清空业务主数据表。 +10. 清空人员、组织、账号、组织绑定、角色绑定。 +11. 清空小程序业务数据和小程序主数据。 +12. 清空系统广播、单据归档记录。 +13. 重置相关自增 ID。 +14. 重建系统权限、角色、角色权限、系统配置、单位、分类、仓库、库位、根组织、超级管理员。 +15. `SET FOREIGN_KEY_CHECKS=1`。 +16. 再次统计关键表行数。 +17. 输出 JSON 报告到 `outputs/cleanup_backups/system_init_summary_.json`。 + +## 小程序表覆盖范围 + +ERP 当前代码已映射部分小程序表,但小程序后端模型更完整。标准初始化必须覆盖两侧共同需要的完整表集合。 + +小程序基础主数据: + +- `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` + +`fresh` 模式需要创建这些表。`reset` 模式需要清空这些表,并只重建必要的考勤点/管理员人员数据。 + +## 清理表分层 + +### 业务流水表 + +销售: + +- `so_sales_order_item` +- `so_sales_order` +- `so_delivery_item` +- `so_delivery` + +采购: + +- `po_purchase_order_sales_order` +- `po_purchase_order_item` +- `po_purchase_order` +- `po_receipt_item` +- `po_receipt` +- `po_purchase_return_item` +- `po_purchase_return` + +库存: + +- `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` + +生产: + +- `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` + +发货与售后: + +- `rt_return_disposition` +- `rt_return_item` +- `rt_return_order` + +财务: + +- `fi_statement_snapshot` +- `fi_cost_allocation` +- `fi_overhead_entry` +- `fi_accounting_period` +- `reconciliation_ledger_entries` + +单据归档: + +- `document_archives` + +MRP: + +- `mrp_material_demand` + +### 业务主数据表 + +- `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_user_role` +- `sys_user` +- `sys_org_manager_binding` +- `sys_org_employee_binding` +- `hr_employee` +- `sys_department` + +需要重建: + +- 根组织节点,例如 `COMPANY_ROOT` +- 超级管理员人员 +- 超级管理员系统账号 +- 超级管理员角色绑定 + +### 系统骨架 + +需要保留或重建: + +- `sys_permission` +- `sys_role` +- `sys_role_permission` +- `sys_system_config` +- `sys_ai_assistant_config` +- `md_unit` +- `md_item_category` +- `wh_warehouse` +- `wh_location` + +需要清空: + +- `sys_broadcast_message` + +## 系统配置初始化 + +必须至少初始化: + +- `RAW_MATERIAL_LOT_PREFIX`:默认 `YL`。 +- `SMART_OPERATION_REPORT_ENABLED`:默认可通过参数设置。百华当前建议按实际购买情况设置。 +- AI 助手名称:默认 `工艺助手`。 + +配置项必须使用 upsert,确保 `fresh` 和 `reset` 都可重复执行。 + +## 仓库初始化 + +初始化六大库和默认库位: + +- 原材料库:`RAW` +- 半成品库:`SEMI` +- 成品库:`FINISHED` +- 辅料库:`AUX` +- 废料库:`SCRAP` +- 退货库:`RETURN` + +每个仓库创建一个默认库位。命名可保持当前业务通用名称,例如: + +- 原料主库位 +- 半成品主库位 +- 成品主库位 +- 辅料主库位 +- 废料主库位 +- 退货主库位 + +仓库名可根据公司名生成,例如 `百华原材料库`,也可以保持通用名 `原材料库`。推荐保持通用名,避免系统内部业务文案过长。 + +## 备份与报告 + +`reset` 模式必须生成备份: + +```text +outputs/cleanup_backups/system_init_backup_.sql +``` + +必须生成报告: + +```text +outputs/cleanup_backups/system_init_summary_.json +``` + +报告内容包括: + +- 执行模式 +- 执行时间 +- 数据库 host、port、database +- 备份路径 +- 清理表清单 +- 每张表清理前行数 +- 每张表清理后行数 +- 重建骨架数据摘要 +- 是否清理文件 +- 执行结果 + +## 文件清理 + +默认不删除实体文件,只清数据库引用。 + +如显式传入 `--delete-files`,才允许清理: + +- 单据归档 PDF 目录 +- 批量下载临时 zip 目录 +- 辅助照片上传目录 + +文件清理必须记录到报告中。 + +## 安全保护 + +`reset` 模式必须满足: + +- 未传 `--confirm-reset` 时禁止执行删除,只输出预检摘要。 +- 执行前必须成功备份。 +- 打印目标 database,并要求命令行参数显式确认。 +- 默认拒绝清理未知 database,除非传入 `--allow-any-database`。 +- 清理前后检查 `FOREIGN_KEY_CHECKS` 恢复为 `1`。 +- 清理后必须能查询到一个超级管理员用户。 +- 清理后业务流水表行数必须为 `0`。 + +## 测试策略 + +单元测试: + +- `fresh` 在临时 SQLite 或测试 MySQL schema 上可创建所有 SQLAlchemy metadata 表。 +- `reset` 对带测试数据的测试库执行后,业务表为空。 +- `reset` 后系统账号、角色、权限、仓库、库位、系统配置存在。 +- `reset` 未传 `--confirm-reset` 时不会删除数据。 +- 小程序关键表存在,并可写入最小人员、考勤点、产品清单、报工记录。 + +集成测试: + +- 初始化后调用 `/api/system/health` 返回数据库连接正常。 +- 初始化后用超级管理员登录成功。 +- 初始化后打开仓库页面不会因为缺仓库/库位报错。 +- 初始化后打开系统权限管理页面能看到根组织节点和超级管理员。 + +人工验收: + +- 前端登录成功。 +- 左侧菜单正常。 +- 基础资料中客户、供应商、产品、物料为空。 +- 六大仓库库存为空,但仓库入口可正常进入。 +- 销售、采购、生产、发货、财务台账为空。 +- 系统管理中只有初始化创建的超级管理员账号。 + +## 实施顺序 + +1. 补齐小程序完整 SQLAlchemy 模型或标准 SQL,确保初始化可创建小程序全部表。 +2. 新增初始化脚本 `backend/scripts/system_initialize.py`。 +3. 新增 SQL seed/cleanup 分组文件。 +4. 编写测试覆盖 `fresh` 和 `reset`。 +5. 在本地测试库执行 `fresh`。 +6. 在本地测试库导入当前测试数据后执行 `reset`。 +7. 确认报告和备份文件。 +8. 用户审核后,在百华服务器数据库执行 `reset`。 + +## 开放决策 + +本设计已固定采用“只保留一个超级管理员”的账号策略。 + +执行前仍需确定: + +- 百华超级管理员姓名、手机号、初始密码。 +- 百华是否默认开启智能报工小程序对接。 +- 是否在本次初始化时删除服务器上的历史上传图片和 PDF 实体文件。默认不删除。