feat: add standard system initialization tooling
This commit is contained in:
parent
442713b4ef
commit
f957899500
@ -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.document_archive import DocumentArchive
|
||||||
from app.models.org import Department, Employee, Permission, Role, RolePermission, User, UserRole
|
from app.models.org import Department, Employee, Permission, Role, RolePermission, User, UserRole
|
||||||
from app.models.planning import MaterialDemand
|
from app.models.planning import MaterialDemand
|
||||||
@ -12,6 +14,7 @@ __all__ = [
|
|||||||
"Bom",
|
"Bom",
|
||||||
"BomItem",
|
"BomItem",
|
||||||
"Item",
|
"Item",
|
||||||
|
"ItemCategory",
|
||||||
"Material",
|
"Material",
|
||||||
"MaterialDemand",
|
"MaterialDemand",
|
||||||
"Permission",
|
"Permission",
|
||||||
|
|||||||
@ -17,6 +17,20 @@ class Unit(Base):
|
|||||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
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):
|
class Item(Base):
|
||||||
__tablename__ = "md_item"
|
__tablename__ = "md_item"
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
@ -13,6 +13,16 @@ class MiniAppAttendancePoint(Base):
|
|||||||
latitude: Mapped[float | None] = mapped_column(DECIMAL(10, 7), nullable=True)
|
latitude: Mapped[float | None] = mapped_column(DECIMAL(10, 7), nullable=True)
|
||||||
longitude: 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"))
|
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)
|
remark: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
|
is_active: Mapped[bool] = mapped_column(Boolean, server_default=text("1"))
|
||||||
created_at: Mapped[object] = mapped_column(DateTime)
|
created_at: Mapped[object] = mapped_column(DateTime)
|
||||||
@ -49,7 +59,7 @@ class MiniAppPersonAttendancePoint(Base):
|
|||||||
class MiniAppProduct(Base):
|
class MiniAppProduct(Base):
|
||||||
__tablename__ = "products"
|
__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)
|
project_no: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
product_name: Mapped[str] = mapped_column(String(255), 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)
|
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)
|
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)
|
stamping_method: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
operator_count: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("1"))
|
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_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"))
|
standard_workload: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0"))
|
||||||
created_at: Mapped[object] = mapped_column(DateTime)
|
created_at: Mapped[object] = mapped_column(DateTime)
|
||||||
updated_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):
|
class MiniAppProductionReport(Base):
|
||||||
__tablename__ = "production_reports"
|
__tablename__ = "production_reports"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("'宁波百华智能科技有限公司'"))
|
attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("''"))
|
||||||
session_id: Mapped[int] = mapped_column(BigInteger)
|
session_id: Mapped[int] = mapped_column(ForeignKey("work_sessions.id"), unique=True)
|
||||||
employee_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
|
employee_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"))
|
||||||
report_date: Mapped[object] = mapped_column(Date)
|
report_date: Mapped[object] = mapped_column(Date)
|
||||||
start_at: Mapped[object] = mapped_column(DateTime)
|
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)
|
reviewer_phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
reviewed_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
reviewed_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
||||||
reject_reason: Mapped[str | None] = mapped_column(String(500), 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)
|
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)
|
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):
|
class MiniAppProductionReportItem(Base):
|
||||||
__tablename__ = "production_report_items"
|
__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"))
|
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))
|
device_no: Mapped[str] = mapped_column(String(64))
|
||||||
project_no: Mapped[str] = mapped_column(String(64))
|
project_no: Mapped[str] = mapped_column(String(64))
|
||||||
product_name: Mapped[str] = mapped_column(String(255))
|
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)
|
raw_material_batch_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
process_name: 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)
|
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_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"))
|
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"))
|
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"))
|
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"))
|
allocated_minutes: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("0"))
|
||||||
started_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
|
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)
|
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):
|
class MiniAppNotice(Base):
|
||||||
__tablename__ = "notices"
|
__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))
|
title: Mapped[str] = mapped_column(String(120))
|
||||||
content: Mapped[str] = mapped_column(Text)
|
content: Mapped[str] = mapped_column(Text)
|
||||||
sort_order: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
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)
|
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)
|
attendance_point_name: Mapped[str] = mapped_column(ForeignKey("attendance_points.name"), primary_key=True)
|
||||||
created_at: Mapped[object] = mapped_column(DateTime)
|
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"),
|
||||||
|
)
|
||||||
|
|||||||
@ -559,6 +559,39 @@ class WorkOrderMaterialIssue(Base):
|
|||||||
updated_at: Mapped[object] = mapped_column(DateTime)
|
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):
|
class ProductionBatchLedger(Base):
|
||||||
__tablename__ = "pp_production_batch_ledger"
|
__tablename__ = "pp_production_batch_ledger"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|||||||
1040
backend/app/services/system_initializer.py
Normal file
1040
backend/app/services/system_initializer.py
Normal file
File diff suppressed because it is too large
Load Diff
1
backend/scripts/__init__.py
Normal file
1
backend/scripts/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Operational scripts for ForgeFlow ERP backend."""
|
||||||
89
backend/scripts/system_initialize.py
Normal file
89
backend/scripts/system_initialize.py
Normal 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())
|
||||||
152
backend/tests/test_system_initializer_cli.py
Normal file
152
backend/tests/test_system_initializer_cli.py
Normal 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()
|
||||||
97
backend/tests/test_system_initializer_models.py
Normal file
97
backend/tests/test_system_initializer_models.py
Normal 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)
|
||||||
328
backend/tests/test_system_initializer_reset.py
Normal file
328
backend/tests/test_system_initializer_reset.py
Normal 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()
|
||||||
177
backend/tests/test_system_initializer_seed.py
Normal file
177
backend/tests/test_system_initializer_seed.py
Normal 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()
|
||||||
2098
docs/superpowers/plans/2026-06-14-system-initialization-bootstrap.md
Normal file
2098
docs/superpowers/plans/2026-06-14-system-initialization-bootstrap.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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 实体文件。默认不删除。
|
||||||
Loading…
Reference in New Issue
Block a user