feat: add standard system initialization tooling

This commit is contained in:
汤学会 2026-06-15 00:14:21 +08:00
parent 442713b4ef
commit f957899500
13 changed files with 4636 additions and 9 deletions

View File

@ -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",

View File

@ -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"

View File

@ -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"),
)

View File

@ -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__ = (

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
"""Operational scripts for ForgeFlow ERP backend."""

View File

@ -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())

View File

@ -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()

View File

@ -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)

View File

@ -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()

View File

@ -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()

File diff suppressed because it is too large Load Diff

View File

@ -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_<timestamp>.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_<timestamp>.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 助手名称:默认 `<company-name>工艺助手`
配置项必须使用 upsert确保 `fresh``reset` 都可重复执行。
## 仓库初始化
初始化六大库和默认库位:
- 原材料库:`RAW`
- 半成品库:`SEMI`
- 成品库:`FINISHED`
- 辅料库:`AUX`
- 废料库:`SCRAP`
- 退货库:`RETURN`
每个仓库创建一个默认库位。命名可保持当前业务通用名称,例如:
- 原料主库位
- 半成品主库位
- 成品主库位
- 辅料主库位
- 废料主库位
- 退货主库位
仓库名可根据公司名生成,例如 `百华原材料库`,也可以保持通用名 `原材料库`。推荐保持通用名,避免系统内部业务文案过长。
## 备份与报告
`reset` 模式必须生成备份:
```text
outputs/cleanup_backups/system_init_backup_<timestamp>.sql
```
必须生成报告:
```text
outputs/cleanup_backups/system_init_summary_<timestamp>.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 实体文件。默认不删除。