Gogs преди 6 месеца
родител
ревизия
1dd1b662d4
променени са 100 файла, в които са добавени 2736 реда и са изтрити 0 реда
  1. 0 0
      bx/bx-backend/app/__init__.py
  2. BIN
      bx/bx-backend/app/__pycache__/__init__.cpython-311.pyc
  3. BIN
      bx/bx-backend/app/__pycache__/config.cpython-311.pyc
  4. BIN
      bx/bx-backend/app/__pycache__/database.cpython-311.pyc
  5. BIN
      bx/bx-backend/app/__pycache__/main.cpython-311.pyc
  6. 42 0
      bx/bx-backend/app/config.py
  7. 0 0
      bx/bx-backend/app/core/__init__.py
  8. BIN
      bx/bx-backend/app/core/__pycache__/__init__.cpython-311.pyc
  9. BIN
      bx/bx-backend/app/core/__pycache__/deps.cpython-311.pyc
  10. BIN
      bx/bx-backend/app/core/__pycache__/security.cpython-311.pyc
  11. 55 0
      bx/bx-backend/app/core/deps.py
  12. 39 0
      bx/bx-backend/app/core/security.py
  13. 33 0
      bx/bx-backend/app/database.py
  14. 56 0
      bx/bx-backend/app/main.py
  15. 25 0
      bx/bx-backend/app/models/__init__.py
  16. BIN
      bx/bx-backend/app/models/__pycache__/__init__.cpython-311.pyc
  17. BIN
      bx/bx-backend/app/models/__pycache__/advance.cpython-311.pyc
  18. BIN
      bx/bx-backend/app/models/__pycache__/attachment.cpython-311.pyc
  19. BIN
      bx/bx-backend/app/models/__pycache__/base.cpython-311.pyc
  20. BIN
      bx/bx-backend/app/models/__pycache__/expense_item.cpython-311.pyc
  21. BIN
      bx/bx-backend/app/models/__pycache__/menu.cpython-311.pyc
  22. BIN
      bx/bx-backend/app/models/__pycache__/reimburse.cpython-311.pyc
  23. BIN
      bx/bx-backend/app/models/__pycache__/role.cpython-311.pyc
  24. BIN
      bx/bx-backend/app/models/__pycache__/user.cpython-311.pyc
  25. BIN
      bx/bx-backend/app/models/__pycache__/workflow.cpython-311.pyc
  26. 46 0
      bx/bx-backend/app/models/advance.py
  27. 23 0
      bx/bx-backend/app/models/attachment.py
  28. 29 0
      bx/bx-backend/app/models/base.py
  29. 16 0
      bx/bx-backend/app/models/expense_item.py
  30. 44 0
      bx/bx-backend/app/models/menu.py
  31. 66 0
      bx/bx-backend/app/models/reimburse.py
  32. 21 0
      bx/bx-backend/app/models/role.py
  33. 35 0
      bx/bx-backend/app/models/user.py
  34. 27 0
      bx/bx-backend/app/models/workflow.py
  35. 0 0
      bx/bx-backend/app/routers/__init__.py
  36. BIN
      bx/bx-backend/app/routers/__pycache__/__init__.cpython-311.pyc
  37. BIN
      bx/bx-backend/app/routers/__pycache__/advance.cpython-311.pyc
  38. BIN
      bx/bx-backend/app/routers/__pycache__/attachment.cpython-311.pyc
  39. BIN
      bx/bx-backend/app/routers/__pycache__/auth.cpython-311.pyc
  40. BIN
      bx/bx-backend/app/routers/__pycache__/expense_item.cpython-311.pyc
  41. BIN
      bx/bx-backend/app/routers/__pycache__/menu.cpython-311.pyc
  42. BIN
      bx/bx-backend/app/routers/__pycache__/payment.cpython-311.pyc
  43. BIN
      bx/bx-backend/app/routers/__pycache__/reimburse.cpython-311.pyc
  44. BIN
      bx/bx-backend/app/routers/__pycache__/role.cpython-311.pyc
  45. BIN
      bx/bx-backend/app/routers/__pycache__/user.cpython-311.pyc
  46. BIN
      bx/bx-backend/app/routers/__pycache__/workflow.cpython-311.pyc
  47. 123 0
      bx/bx-backend/app/routers/advance.py
  48. 66 0
      bx/bx-backend/app/routers/attachment.py
  49. 81 0
      bx/bx-backend/app/routers/auth.py
  50. 48 0
      bx/bx-backend/app/routers/expense_item.py
  51. 54 0
      bx/bx-backend/app/routers/menu.py
  52. 102 0
      bx/bx-backend/app/routers/payment.py
  53. 133 0
      bx/bx-backend/app/routers/reimburse.py
  54. 60 0
      bx/bx-backend/app/routers/role.py
  55. 62 0
      bx/bx-backend/app/routers/user.py
  56. 88 0
      bx/bx-backend/app/routers/workflow.py
  57. 63 0
      bx/bx-backend/app/schemas/__init__.py
  58. BIN
      bx/bx-backend/app/schemas/__pycache__/__init__.cpython-311.pyc
  59. BIN
      bx/bx-backend/app/schemas/__pycache__/advance.cpython-311.pyc
  60. BIN
      bx/bx-backend/app/schemas/__pycache__/attachment.cpython-311.pyc
  61. BIN
      bx/bx-backend/app/schemas/__pycache__/common.cpython-311.pyc
  62. BIN
      bx/bx-backend/app/schemas/__pycache__/expense_item.cpython-311.pyc
  63. BIN
      bx/bx-backend/app/schemas/__pycache__/menu.cpython-311.pyc
  64. BIN
      bx/bx-backend/app/schemas/__pycache__/reimburse.cpython-311.pyc
  65. BIN
      bx/bx-backend/app/schemas/__pycache__/role.cpython-311.pyc
  66. BIN
      bx/bx-backend/app/schemas/__pycache__/user.cpython-311.pyc
  67. BIN
      bx/bx-backend/app/schemas/__pycache__/workflow.cpython-311.pyc
  68. 74 0
      bx/bx-backend/app/schemas/advance.py
  69. 29 0
      bx/bx-backend/app/schemas/attachment.py
  70. 52 0
      bx/bx-backend/app/schemas/common.py
  71. 47 0
      bx/bx-backend/app/schemas/expense_item.py
  72. 49 0
      bx/bx-backend/app/schemas/menu.py
  73. 80 0
      bx/bx-backend/app/schemas/reimburse.py
  74. 39 0
      bx/bx-backend/app/schemas/role.py
  75. 85 0
      bx/bx-backend/app/schemas/user.py
  76. 57 0
      bx/bx-backend/app/schemas/workflow.py
  77. 0 0
      bx/bx-backend/app/services/__init__.py
  78. BIN
      bx/bx-backend/app/services/__pycache__/__init__.cpython-311.pyc
  79. BIN
      bx/bx-backend/app/services/__pycache__/advance.cpython-311.pyc
  80. BIN
      bx/bx-backend/app/services/__pycache__/attachment.cpython-311.pyc
  81. BIN
      bx/bx-backend/app/services/__pycache__/base.cpython-311.pyc
  82. BIN
      bx/bx-backend/app/services/__pycache__/expense_item.cpython-311.pyc
  83. BIN
      bx/bx-backend/app/services/__pycache__/menu.cpython-311.pyc
  84. BIN
      bx/bx-backend/app/services/__pycache__/minio.cpython-311.pyc
  85. BIN
      bx/bx-backend/app/services/__pycache__/reimburse.cpython-311.pyc
  86. BIN
      bx/bx-backend/app/services/__pycache__/role.cpython-311.pyc
  87. BIN
      bx/bx-backend/app/services/__pycache__/user.cpython-311.pyc
  88. BIN
      bx/bx-backend/app/services/__pycache__/workflow.cpython-311.pyc
  89. 186 0
      bx/bx-backend/app/services/advance.py
  90. 40 0
      bx/bx-backend/app/services/attachment.py
  91. 66 0
      bx/bx-backend/app/services/base.py
  92. 57 0
      bx/bx-backend/app/services/expense_item.py
  93. 45 0
      bx/bx-backend/app/services/menu.py
  94. 60 0
      bx/bx-backend/app/services/minio.py
  95. 202 0
      bx/bx-backend/app/services/reimburse.py
  96. 35 0
      bx/bx-backend/app/services/role.py
  97. 49 0
      bx/bx-backend/app/services/user.py
  98. 47 0
      bx/bx-backend/app/services/workflow.py
  99. 0 0
      bx/bx-backend/app/utils/__init__.py
  100. BIN
      bx/bx-backend/app/utils/__pycache__/__init__.cpython-311.pyc

+ 0 - 0
bx/bx-backend/app/__init__.py


BIN
bx/bx-backend/app/__pycache__/__init__.cpython-311.pyc


BIN
bx/bx-backend/app/__pycache__/config.cpython-311.pyc


BIN
bx/bx-backend/app/__pycache__/database.cpython-311.pyc


BIN
bx/bx-backend/app/__pycache__/main.cpython-311.pyc


+ 42 - 0
bx/bx-backend/app/config.py

@@ -0,0 +1,42 @@
+from pydantic_settings import BaseSettings
+from functools import lru_cache
+
+
+class Settings(BaseSettings):
+    # Database
+    DB_HOST: str = "47.99.164.66"
+    DB_PORT: int = 3306
+    DB_USER: str = "root"
+    DB_PASSWORD: str = ""
+    DB_NAME: str = "sx"
+
+    # MinIO
+    MINIO_ENDPOINT: str = "47.99.164.66:20004"
+    MINIO_ACCESS_KEY: str = "minioadmin"
+    MINIO_SECRET_KEY: str = "minioadmin"
+    MINIO_BUCKET: str = "ruoyi"
+    MINIO_SECURE: bool = False
+
+    # JWT
+    JWT_SECRET_KEY: str = "bx-system-jwt-secret-key-2026"
+    JWT_ALGORITHM: str = "HS256"
+    JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
+
+    # App
+    APP_HOST: str = "0.0.0.0"
+    APP_PORT: int = 8000
+    DEBUG: bool = True
+
+    @property
+    def DATABASE_URL(self) -> str:
+        return (
+            f"mysql+pymysql://{self.DB_USER}:{self.DB_PASSWORD}"
+            f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4"
+        )
+
+    model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
+
+
+@lru_cache
+def get_settings() -> Settings:
+    return Settings()

+ 0 - 0
bx/bx-backend/app/core/__init__.py


BIN
bx/bx-backend/app/core/__pycache__/__init__.cpython-311.pyc


BIN
bx/bx-backend/app/core/__pycache__/deps.cpython-311.pyc


BIN
bx/bx-backend/app/core/__pycache__/security.cpython-311.pyc


+ 55 - 0
bx/bx-backend/app/core/deps.py

@@ -0,0 +1,55 @@
+"""FastAPI dependencies: DB session, current user, role guards."""
+
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.core.security import decode_access_token
+from app.models.user import User
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
+
+
+def get_current_user(
+    token: str = Depends(oauth2_scheme),
+    db: Session = Depends(get_db),
+) -> User:
+    payload = decode_access_token(token)
+    if payload is None:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="无效或过期的认证凭据",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+    user_id: int | None = payload.get("sub")
+    if user_id is None:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="无效的认证凭据",
+        )
+    user = db.query(User).filter(User.id == user_id, User.del_flag == 0).first()
+    if user is None:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="用户不存在",
+        )
+    if user.status != 1:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="用户已被禁用",
+        )
+    return user
+
+
+def require_roles(*role_codes: str):
+    """Dependency factory: only allow users with one of the given role codes."""
+    def _check(current_user: User = Depends(get_current_user)):
+        user_role_codes = {r.role_code for r in current_user.roles}
+        if not user_role_codes.intersection(set(role_codes)):
+            raise HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail="权限不足",
+            )
+        return current_user
+    return _check

+ 39 - 0
bx/bx-backend/app/core/security.py

@@ -0,0 +1,39 @@
+"""JWT creation / verification and password hashing."""
+
+from datetime import datetime, timedelta, timezone
+
+from jose import jwt, JWTError
+from passlib.context import CryptContext
+
+from app.config import get_settings
+
+settings = get_settings()
+
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+ALGORITHM = settings.JWT_ALGORITHM
+
+
+def hash_password(password: str) -> str:
+    return pwd_context.hash(password)
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+    return pwd_context.verify(plain_password, hashed_password)
+
+
+def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
+    to_encode = data.copy()
+    expire = datetime.now(timezone.utc) + (
+        expires_delta or timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
+    )
+    to_encode.update({"exp": expire})
+    return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM)
+
+
+def decode_access_token(token: str) -> dict | None:
+    """Return decoded payload or None if token is invalid / expired."""
+    try:
+        return jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[ALGORITHM])
+    except JWTError:
+        return None

+ 33 - 0
bx/bx-backend/app/database.py

@@ -0,0 +1,33 @@
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker, DeclarativeBase
+
+from app.config import get_settings
+
+settings = get_settings()
+
+engine = create_engine(
+    settings.DATABASE_URL,
+    echo=settings.DEBUG,
+    pool_pre_ping=True,
+    pool_size=5,
+    max_overflow=10,
+    pool_recycle=3600,
+)
+
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+
+class Base(DeclarativeBase):
+    pass
+
+
+def get_db():
+    db = SessionLocal()
+    try:
+        yield db
+        db.commit()
+    except Exception:
+        db.rollback()
+        raise
+    finally:
+        db.close()

+ 56 - 0
bx/bx-backend/app/main.py

@@ -0,0 +1,56 @@
+"""FastAPI application factory."""
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from app.config import get_settings
+
+settings = get_settings()
+
+
+def create_app() -> FastAPI:
+    app = FastAPI(
+        title="报销系统",
+        version="1.0.0",
+        docs_url="/api/docs",
+        openapi_url="/api/openapi.json",
+    )
+
+    app.add_middleware(
+        CORSMiddleware,
+        allow_origins=["*"],
+        allow_credentials=True,
+        allow_methods=["*"],
+        allow_headers=["*"],
+    )
+
+    from app.routers import auth, user, role, menu, expense_item, workflow, advance, reimburse, attachment, payment
+
+    app.include_router(auth.router)
+    app.include_router(user.router)
+    app.include_router(role.router)
+    app.include_router(menu.router)
+    app.include_router(expense_item.router)
+    app.include_router(workflow.router)
+    app.include_router(advance.router)
+    app.include_router(reimburse.router)
+    app.include_router(attachment.router)
+    app.include_router(payment.router)
+
+    @app.get("/api/health")
+    def health_check():
+        return {"status": "ok"}
+
+    return app
+
+
+app = create_app()
+
+if __name__ == "__main__":
+    import uvicorn
+    uvicorn.run(
+        "app.main:app",
+        host=settings.APP_HOST,
+        port=settings.APP_PORT,
+        reload=settings.DEBUG,
+    )

+ 25 - 0
bx/bx-backend/app/models/__init__.py

@@ -0,0 +1,25 @@
+from app.models.user import RoleUser, User
+from app.models.role import Role
+from app.models.menu import Menu, MenuRole
+from app.models.expense_item import ExpenseItem
+from app.models.workflow import WorkflowDef, WorkflowNode
+from app.models.advance import AdvanceApply, AdvanceApproval
+from app.models.reimburse import ReimburseApply, ReimburseApproval, ReimburseOffset
+from app.models.attachment import Attachment
+
+__all__ = [
+    "RoleUser",
+    "User",
+    "Role",
+    "Menu",
+    "MenuRole",
+    "ExpenseItem",
+    "WorkflowDef",
+    "WorkflowNode",
+    "AdvanceApply",
+    "AdvanceApproval",
+    "ReimburseApply",
+    "ReimburseApproval",
+    "ReimburseOffset",
+    "Attachment",
+]

BIN
bx/bx-backend/app/models/__pycache__/__init__.cpython-311.pyc


BIN
bx/bx-backend/app/models/__pycache__/advance.cpython-311.pyc


BIN
bx/bx-backend/app/models/__pycache__/attachment.cpython-311.pyc


BIN
bx/bx-backend/app/models/__pycache__/base.cpython-311.pyc


BIN
bx/bx-backend/app/models/__pycache__/expense_item.cpython-311.pyc


BIN
bx/bx-backend/app/models/__pycache__/menu.cpython-311.pyc


BIN
bx/bx-backend/app/models/__pycache__/reimburse.cpython-311.pyc


BIN
bx/bx-backend/app/models/__pycache__/role.cpython-311.pyc


BIN
bx/bx-backend/app/models/__pycache__/user.cpython-311.pyc


BIN
bx/bx-backend/app/models/__pycache__/workflow.cpython-311.pyc


+ 46 - 0
bx/bx-backend/app/models/advance.py

@@ -0,0 +1,46 @@
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, Numeric, String, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.database import Base
+from app.models.base import BaseModel, SoftDeleteMixin
+
+
+class AdvanceApply(BaseModel, SoftDeleteMixin):
+    __tablename__ = "bx_advance_apply"
+
+    apply_code: Mapped[str] = mapped_column(String(32), nullable=False, unique=True)
+    applicant_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    applicant_name: Mapped[str] = mapped_column(String(64), nullable=False)
+    reason: Mapped[str] = mapped_column(String(1024), nullable=False)
+    purpose: Mapped[str | None] = mapped_column(String(1024))
+    amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
+    status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft", server_default="draft")
+    current_node: Mapped[str | None] = mapped_column(String(128))
+    wf_id: Mapped[int | None] = mapped_column(BigInteger)
+    issue_status: Mapped[str | None] = mapped_column(String(32))
+    issue_time: Mapped[datetime | None] = mapped_column(DateTime)
+    issue_user_id: Mapped[int | None] = mapped_column(BigInteger)
+    issue_user_name: Mapped[str | None] = mapped_column(String(64))
+    issue_remark: Mapped[str | None] = mapped_column(String(512))
+    remark: Mapped[str | None] = mapped_column(String(512))
+
+    approvals: Mapped[list["AdvanceApproval"]] = relationship(back_populates="apply")
+
+
+class AdvanceApproval(Base):
+    __tablename__ = "bx_advance_approval"
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    apply_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("bx_advance_apply.id"), nullable=False)
+    node_order: Mapped[int] = mapped_column(Integer, nullable=False)
+    node_name: Mapped[str] = mapped_column(String(128), nullable=False)
+    approver_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    approver_name: Mapped[str] = mapped_column(String(64), nullable=False)
+    approval_status: Mapped[str] = mapped_column(String(32), nullable=False)
+    approval_opinion: Mapped[str | None] = mapped_column(String(1024))
+    approval_time: Mapped[datetime] = mapped_column(DateTime, nullable=False)
+
+    apply: Mapped["AdvanceApply"] = relationship(back_populates="approvals")

+ 23 - 0
bx/bx-backend/app/models/attachment.py

@@ -0,0 +1,23 @@
+from datetime import datetime
+
+from sqlalchemy import BigInteger, DateTime, Integer, SmallInteger, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.database import Base
+from app.models.base import SoftDeleteMixin
+
+
+class Attachment(Base, SoftDeleteMixin):
+    __tablename__ = "bx_attachment"
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    biz_type: Mapped[str] = mapped_column(String(32), nullable=False)
+    biz_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    file_name: Mapped[str] = mapped_column(String(256), nullable=False)
+    file_path: Mapped[str] = mapped_column(String(512), nullable=False)
+    file_type: Mapped[str | None] = mapped_column(String(32))
+    file_size: Mapped[int | None] = mapped_column(BigInteger)
+    attach_type: Mapped[str] = mapped_column(String(32), default="attachment", server_default="attachment")
+    sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
+    create_by: Mapped[str | None] = mapped_column(String(64))
+    create_time: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 29 - 0
bx/bx-backend/app/models/base.py

@@ -0,0 +1,29 @@
+"""Base model with common columns shared by all bx_ tables."""
+
+from datetime import datetime
+from sqlalchemy import BigInteger, String, SmallInteger, DateTime, func
+from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase
+
+from app.database import Base
+
+
+class BaseModel(Base):
+    """Abstract base providing id, create_by, create_time, update_by, update_time."""
+    __abstract__ = True
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    create_by: Mapped[str | None] = mapped_column(String(64), default=None)
+    create_time: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now()
+    )
+    update_by: Mapped[str | None] = mapped_column(String(64), default=None)
+    update_time: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
+    )
+
+
+class SoftDeleteMixin:
+    """Mixin for soft-delete support (del_flag column)."""
+    del_flag: Mapped[int] = mapped_column(
+        SmallInteger, nullable=False, default=0, server_default="0"
+    )

+ 16 - 0
bx/bx-backend/app/models/expense_item.py

@@ -0,0 +1,16 @@
+from sqlalchemy import BigInteger, Integer, SmallInteger, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.models.base import BaseModel, SoftDeleteMixin
+
+
+class ExpenseItem(BaseModel, SoftDeleteMixin):
+    __tablename__ = "bx_expense_item"
+
+    parent_id: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0, server_default="0")
+    item_name: Mapped[str] = mapped_column(String(128), nullable=False)
+    item_code: Mapped[str | None] = mapped_column(String(64))
+    level: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1, server_default="1")
+    sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
+    status: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1, server_default="1")
+    remark: Mapped[str | None] = mapped_column(String(512))

+ 44 - 0
bx/bx-backend/app/models/menu.py

@@ -0,0 +1,44 @@
+from datetime import datetime
+
+from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, SmallInteger, String, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.database import Base
+from app.models.base import BaseModel, SoftDeleteMixin
+
+
+class MenuRole(Base):
+    __tablename__ = "bx_menu_role"
+    __table_args__ = (UniqueConstraint("menu_id", "role_id"),)
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    menu_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("bx_menu.id"), nullable=False)
+    role_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("bx_role.id"), nullable=False, index=True)
+    create_by: Mapped[str | None] = mapped_column(String(64))
+    create_time: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+
+class Menu(BaseModel, SoftDeleteMixin):
+    __tablename__ = "bx_menu"
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    parent_id: Mapped[int] = mapped_column(
+        BigInteger, ForeignKey("bx_menu.id"), nullable=False, default=0, server_default="0"
+    )
+    menu_name: Mapped[str] = mapped_column(String(64), nullable=False)
+    menu_code: Mapped[str | None] = mapped_column(String(64))
+    menu_type: Mapped[int] = mapped_column(SmallInteger, nullable=False)
+    path: Mapped[str | None] = mapped_column(String(256))
+    icon: Mapped[str | None] = mapped_column(String(64))
+    sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
+    visible: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1, server_default="1")
+    status: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1, server_default="1")
+    remark: Mapped[str | None] = mapped_column(String(512))
+
+    parent: Mapped["Menu | None"] = relationship(
+        "Menu", remote_side=[id], back_populates="children"
+    )
+    children: Mapped[list["Menu"]] = relationship("Menu", back_populates="parent")
+    roles: Mapped[list["Role"]] = relationship(
+        "Role", secondary="bx_menu_role", back_populates="menus"
+    )

+ 66 - 0
bx/bx-backend/app/models/reimburse.py

@@ -0,0 +1,66 @@
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, Numeric, String, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.database import Base
+from app.models.base import BaseModel, SoftDeleteMixin
+
+
+class ReimburseApply(BaseModel, SoftDeleteMixin):
+    __tablename__ = "bx_reimburse_apply"
+
+    apply_code: Mapped[str] = mapped_column(String(32), nullable=False, unique=True)
+    title: Mapped[str] = mapped_column(String(256), nullable=False)
+    applicant_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    applicant_name: Mapped[str] = mapped_column(String(64), nullable=False)
+    expense_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    expense_name: Mapped[str] = mapped_column(String(128), nullable=False)
+    amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
+    invoice_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
+    attach_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
+    status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft", server_default="draft")
+    current_node: Mapped[str | None] = mapped_column(String(128))
+    wf_id: Mapped[int | None] = mapped_column(BigInteger)
+    pay_status: Mapped[str | None] = mapped_column(String(32))
+    pay_time: Mapped[datetime | None] = mapped_column(DateTime)
+    pay_user_id: Mapped[int | None] = mapped_column(BigInteger)
+    pay_user_name: Mapped[str | None] = mapped_column(String(64))
+    offset_advance_id: Mapped[int | None] = mapped_column(BigInteger)
+    offset_amount: Mapped[Decimal | None] = mapped_column(Numeric(14, 2))
+    remark: Mapped[str | None] = mapped_column(String(512))
+
+    approvals: Mapped[list["ReimburseApproval"]] = relationship(back_populates="apply")
+    offsets: Mapped[list["ReimburseOffset"]] = relationship(back_populates="reimburse")
+
+
+class ReimburseApproval(Base):
+    __tablename__ = "bx_reimburse_approval"
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    apply_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("bx_reimburse_apply.id"), nullable=False)
+    node_order: Mapped[int] = mapped_column(Integer, nullable=False)
+    node_name: Mapped[str] = mapped_column(String(128), nullable=False)
+    approver_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    approver_name: Mapped[str] = mapped_column(String(64), nullable=False)
+    approval_status: Mapped[str] = mapped_column(String(32), nullable=False)
+    approval_opinion: Mapped[str | None] = mapped_column(String(1024))
+    approval_time: Mapped[datetime] = mapped_column(DateTime, nullable=False)
+
+    apply: Mapped["ReimburseApply"] = relationship(back_populates="approvals")
+
+
+class ReimburseOffset(Base):
+    __tablename__ = "bx_reimburse_offset"
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    reimburse_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("bx_reimburse_apply.id"), nullable=False)
+    advance_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("bx_advance_apply.id"), nullable=False)
+    offset_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
+    remark: Mapped[str | None] = mapped_column(String(512))
+    create_by: Mapped[str | None] = mapped_column(String(64))
+    create_time: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    reimburse: Mapped["ReimburseApply"] = relationship(back_populates="offsets")
+    advance: Mapped["AdvanceApply"] = relationship()

+ 21 - 0
bx/bx-backend/app/models/role.py

@@ -0,0 +1,21 @@
+from sqlalchemy import Integer, SmallInteger, String
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.models.base import BaseModel, SoftDeleteMixin
+
+
+class Role(BaseModel, SoftDeleteMixin):
+    __tablename__ = "bx_role"
+
+    role_name: Mapped[str] = mapped_column(String(64), nullable=False)
+    role_code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
+    sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
+    status: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1, server_default="1")
+    remark: Mapped[str | None] = mapped_column(String(512))
+
+    users: Mapped[list["User"]] = relationship(
+        "User", secondary="bx_role_user", back_populates="roles"
+    )
+    menus: Mapped[list["Menu"]] = relationship(
+        "Menu", secondary="bx_menu_role", back_populates="roles"
+    )

+ 35 - 0
bx/bx-backend/app/models/user.py

@@ -0,0 +1,35 @@
+from datetime import datetime
+
+from sqlalchemy import BigInteger, DateTime, ForeignKey, SmallInteger, String, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.database import Base
+from app.models.base import BaseModel, SoftDeleteMixin
+
+
+class RoleUser(Base):
+    __tablename__ = "bx_role_user"
+    __table_args__ = (UniqueConstraint("role_id", "user_id"),)
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    role_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("bx_role.id"), nullable=False)
+    user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("bx_user.id"), nullable=False, index=True)
+    create_by: Mapped[str | None] = mapped_column(String(64))
+    create_time: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+
+class User(BaseModel, SoftDeleteMixin):
+    __tablename__ = "bx_user"
+
+    username: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
+    password: Mapped[str] = mapped_column(String(128), nullable=False)
+    real_name: Mapped[str | None] = mapped_column(String(64))
+    phone: Mapped[str | None] = mapped_column(String(20))
+    email: Mapped[str | None] = mapped_column(String(128))
+    avatar: Mapped[str | None] = mapped_column(String(256))
+    status: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1, server_default="1")
+    remark: Mapped[str | None] = mapped_column(String(512))
+
+    roles: Mapped[list["Role"]] = relationship(
+        "Role", secondary="bx_role_user", back_populates="users"
+    )

+ 27 - 0
bx/bx-backend/app/models/workflow.py

@@ -0,0 +1,27 @@
+from sqlalchemy import BigInteger, ForeignKey, Integer, SmallInteger, String
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.models.base import BaseModel, SoftDeleteMixin
+
+
+class WorkflowDef(BaseModel, SoftDeleteMixin):
+    __tablename__ = "bx_workflow_def"
+
+    biz_type: Mapped[str] = mapped_column(String(32), nullable=False)
+    wf_name: Mapped[str] = mapped_column(String(128), nullable=False)
+    status: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1, server_default="1")
+    remark: Mapped[str | None] = mapped_column(String(512))
+
+    nodes: Mapped[list["WorkflowNode"]] = relationship(back_populates="workflow_def")
+
+
+class WorkflowNode(BaseModel):
+    __tablename__ = "bx_workflow_node"
+
+    wf_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("bx_workflow_def.id"), nullable=False)
+    node_name: Mapped[str] = mapped_column(String(128), nullable=False)
+    node_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
+    approver_id: Mapped[int | None] = mapped_column(BigInteger)
+    approver_name: Mapped[str | None] = mapped_column(String(64))
+
+    workflow_def: Mapped["WorkflowDef"] = relationship(back_populates="nodes")

+ 0 - 0
bx/bx-backend/app/routers/__init__.py


BIN
bx/bx-backend/app/routers/__pycache__/__init__.cpython-311.pyc


BIN
bx/bx-backend/app/routers/__pycache__/advance.cpython-311.pyc


BIN
bx/bx-backend/app/routers/__pycache__/attachment.cpython-311.pyc


BIN
bx/bx-backend/app/routers/__pycache__/auth.cpython-311.pyc


BIN
bx/bx-backend/app/routers/__pycache__/expense_item.cpython-311.pyc


BIN
bx/bx-backend/app/routers/__pycache__/menu.cpython-311.pyc


BIN
bx/bx-backend/app/routers/__pycache__/payment.cpython-311.pyc


BIN
bx/bx-backend/app/routers/__pycache__/reimburse.cpython-311.pyc


BIN
bx/bx-backend/app/routers/__pycache__/role.cpython-311.pyc


BIN
bx/bx-backend/app/routers/__pycache__/user.cpython-311.pyc


BIN
bx/bx-backend/app/routers/__pycache__/workflow.cpython-311.pyc


+ 123 - 0
bx/bx-backend/app/routers/advance.py

@@ -0,0 +1,123 @@
+from typing import List
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import or_
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.core.deps import get_current_user
+from app.models.user import User
+from app.models.advance import AdvanceApply
+from app.schemas.common import ApiResponse, PagedData
+from app.schemas.advance import AdvanceApplyCreate, AdvanceApplyUpdate, AdvanceApplyOut, AdvanceApprovalCreate, AdvanceApprovalOut
+from app.services.advance import AdvanceService
+from app.models.workflow import WorkflowDef
+from app.utils.codegen import generate_apply_code
+
+router = APIRouter(prefix="/api/advances", tags=["备用金管理"])
+
+
+@router.get("/", response_model=ApiResponse[PagedData[AdvanceApplyOut]])
+def list_advances(
+    page: int = 1,
+    pageSize: int = 10,
+    status: str | None = None,
+    keyword: str = "",
+    db: Session = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+):
+    filters = [AdvanceApply.del_flag == 0]
+    if status:
+        filters.append(AdvanceApply.status == status)
+    if keyword:
+        filters.append(or_(AdvanceApply.apply_code.contains(keyword), AdvanceApply.applicant_name.contains(keyword)))
+    rows, total = AdvanceService.get_paged(db, page=page, page_size=pageSize, filters=filters)
+    out_rows = [AdvanceApplyOut.model_validate(r) for r in rows]
+    paged_data = PagedData(rows=out_rows, total=total, page=page, page_size=pageSize)
+    return ApiResponse(data=paged_data)
+
+
+@router.get("/my", response_model=ApiResponse[PagedData[AdvanceApplyOut]])
+def my_advances(
+    page: int = 1,
+    pageSize: int = 10,
+    status: str | None = None,
+    keyword: str = "",
+    db: Session = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+):
+    filters = [AdvanceApply.del_flag == 0, AdvanceApply.applicant_id == current_user.id]
+    if status:
+        filters.append(AdvanceApply.status == status)
+    if keyword:
+        filters.append(or_(AdvanceApply.apply_code.contains(keyword), AdvanceApply.applicant_name.contains(keyword)))
+    rows, total = AdvanceService.get_paged(db, page=page, page_size=pageSize, filters=filters)
+    out_rows = [AdvanceApplyOut.model_validate(r) for r in rows]
+    paged_data = PagedData(rows=out_rows, total=total, page=page, page_size=pageSize)
+    return ApiResponse(data=paged_data)
+
+
+@router.get("/{advance_id}", response_model=ApiResponse[AdvanceApplyOut])
+def get_advance(advance_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
+    advance = AdvanceService.get_by_id(db, advance_id)
+    if not advance:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
+    return ApiResponse(data=AdvanceApplyOut.model_validate(advance))
+
+
+@router.get("/{advance_id}/approvals", response_model=ApiResponse[List[AdvanceApprovalOut]])
+def get_approvals(advance_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
+    approvals = AdvanceService.get_approvals(db, advance_id)
+    return ApiResponse(data=[AdvanceApprovalOut.model_validate(a) for a in approvals])
+
+
+@router.post("/", response_model=ApiResponse[AdvanceApplyOut])
+def create_advance(body: AdvanceApplyCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
+    data = body.model_dump(exclude_unset=True)
+    data["applicant_id"] = current_user.id
+    data["applicant_name"] = current_user.real_name or current_user.username
+    data["apply_code"] = generate_apply_code(db, "BYJ")
+    data["status"] = "draft"
+    wf = db.query(WorkflowDef).filter(WorkflowDef.biz_type == "advance", WorkflowDef.del_flag == 0).first()
+    if wf:
+        data["wf_id"] = wf.id
+    advance = AdvanceService.create(db, data)
+    return ApiResponse(data=AdvanceApplyOut.model_validate(advance))
+
+
+@router.put("/{advance_id}", response_model=ApiResponse[AdvanceApplyOut])
+def update_advance(advance_id: int, body: AdvanceApplyUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
+    data = body.model_dump(exclude_unset=True)
+    advance = AdvanceService.update(db, advance_id, data)
+    if not advance:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
+    return ApiResponse(data=AdvanceApplyOut.model_validate(advance))
+
+
+@router.post("/{advance_id}/submit", response_model=ApiResponse[AdvanceApplyOut])
+def submit_advance(advance_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
+    advance = AdvanceService.submit(db, advance_id, current_user)
+    if not advance:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
+    return ApiResponse(data=AdvanceApplyOut.model_validate(advance))
+
+
+@router.post("/{advance_id}/approve", response_model=ApiResponse[AdvanceApplyOut])
+def approve_advance(advance_id: int, body: AdvanceApprovalCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
+    advance = AdvanceService.approve(db, advance_id, current_user, body.approval_opinion or "")
+    if not advance:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
+    return ApiResponse(data=AdvanceApplyOut.model_validate(advance))
+
+
+@router.post("/{advance_id}/reject", response_model=ApiResponse[AdvanceApplyOut])
+def reject_advance(advance_id: int, body: AdvanceApprovalCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
+    advance = AdvanceService.reject(db, advance_id, current_user, body.approval_opinion or "")
+    if not advance:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
+    return ApiResponse(data=AdvanceApplyOut.model_validate(advance))
+
+
+@router.delete("/{advance_id}", response_model=ApiResponse[None])
+def delete_advance(advance_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
+    AdvanceService.soft_delete(db, advance_id)
+    return ApiResponse(data=None)

+ 66 - 0
bx/bx-backend/app/routers/attachment.py

@@ -0,0 +1,66 @@
+import mimetypes
+from io import BytesIO
+from typing import List
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
+from fastapi.responses import StreamingResponse
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.core.deps import get_current_user
+from app.models.user import User
+from app.schemas.common import ApiResponse
+from app.schemas.attachment import AttachmentOut
+from app.services.attachment import AttachmentService
+from app.services.minio import MinioService
+
+router = APIRouter(prefix="/api/attachments", tags=["附件管理"])
+
+
+@router.post("/upload", response_model=ApiResponse[AttachmentOut])
+def upload_file(
+    file: UploadFile = File(...),
+    biz_type: str = "",
+    biz_id: int = 0,
+    db: Session = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+):
+    file_data = file.file.read()
+    content_type = file.content_type or mimetypes.guess_type(file.filename or "")[0] or "application/octet-stream"
+    object_name = MinioService.upload_file(file.filename or "unknown", file_data, content_type)
+    data = {
+        "biz_type": biz_type,
+        "biz_id": biz_id,
+        "file_name": file.filename or "unknown",
+        "file_path": object_name,
+        "file_type": content_type,
+        "file_size": len(file_data),
+        "create_by": str(current_user.id),
+    }
+    attachment = AttachmentService.create(db, data)
+    return ApiResponse(data=AttachmentOut.model_validate(attachment))
+
+
+@router.get("/download/{attachment_id}")
+def download_file(
+    attachment_id: int,
+    db: Session = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+):
+    attachment = AttachmentService.get_by_id(db, attachment_id)
+    if not attachment:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
+    file_data = MinioService.download_file(attachment.file_path)
+    file_stream = BytesIO(file_data)
+    headers = {"Content-Disposition": f"attachment; filename={attachment.file_name}"}
+    return StreamingResponse(file_stream, media_type=attachment.file_type or "application/octet-stream", headers=headers)
+
+
+@router.get("/list", response_model=ApiResponse[List[AttachmentOut]])
+def list_by_biz(
+    biz_type: str = "",
+    biz_id: int = 0,
+    db: Session = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+):
+    attachments = AttachmentService.get_by_biz(db, biz_type=biz_type, biz_id=biz_id)
+    return ApiResponse(data=[AttachmentOut.model_validate(a) for a in attachments])

+ 81 - 0
bx/bx-backend/app/routers/auth.py

@@ -0,0 +1,81 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from pydantic import BaseModel, ConfigDict
+from pydantic.alias_generators import to_camel
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.core.security import verify_password, hash_password, create_access_token
+from app.core.deps import get_current_user
+from app.models.user import User
+from app.schemas.common import ApiResponse
+from app.schemas.user import UserOut
+
+router = APIRouter(prefix="/api/auth", tags=["认证"])
+
+
+class LoginRequest(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    username: str
+    password: str
+
+
+class RegisterRequest(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    username: str
+    password: str
+    real_name: str
+
+
+class TokenResponse(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    access_token: str
+    token_type: str = "bearer"
+
+
+@router.post("/login", response_model=ApiResponse[TokenResponse])
+def login(body: LoginRequest, db: Session = Depends(get_db)):
+    user = db.query(User).filter(User.username == body.username, User.del_flag == 0).first()
+    if not user or not verify_password(body.password, user.password):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="用户名或密码错误",
+        )
+    if user.status != 1:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="用户已被禁用",
+        )
+    token = create_access_token(data={"sub": str(user.id)})
+    return ApiResponse(data=TokenResponse(access_token=token))
+
+
+@router.post("/register", response_model=ApiResponse[UserOut])
+def register(body: RegisterRequest, db: Session = Depends(get_db)):
+    existing = db.query(User).filter(User.username == body.username, User.del_flag == 0).first()
+    if existing:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="用户名已存在",
+        )
+    from app.models.role import Role
+    employee_role = db.query(Role).filter(Role.role_code == "EMPLOYEE", Role.del_flag == 0).first()
+    user = User(
+        username=body.username,
+        password=hash_password(body.password),
+        real_name=body.real_name,
+        status=1,
+    )
+    if employee_role:
+        user.roles.append(employee_role)
+    db.add(user)
+    db.commit()
+    db.refresh(user)
+    return ApiResponse(data=UserOut.model_validate(user))
+
+
+@router.get("/me", response_model=ApiResponse[UserOut])
+def get_current_user_info(current_user: User = Depends(get_current_user)):
+    return ApiResponse(data=UserOut.model_validate(current_user))

+ 48 - 0
bx/bx-backend/app/routers/expense_item.py

@@ -0,0 +1,48 @@
+from typing import List
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.core.deps import require_roles
+from app.models.user import User
+from app.schemas.common import ApiResponse
+from app.schemas.expense_item import ExpenseItemCreate, ExpenseItemUpdate, ExpenseItemOut
+from app.services.expense_item import ExpenseItemService
+
+router = APIRouter(prefix="/api/expense-items", tags=["费用项目管理"])
+
+
+@router.get("/tree", response_model=ApiResponse[List[ExpenseItemOut]])
+def get_expense_item_tree(db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    tree = ExpenseItemService.get_item_tree(db)
+    return ApiResponse(data=[ExpenseItemOut.model_validate(i) for i in tree])
+
+
+@router.get("/{item_id}", response_model=ApiResponse[ExpenseItemOut])
+def get_expense_item(item_id: int, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    item = ExpenseItemService.get_by_id(db, item_id)
+    if not item:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="费用项目不存在")
+    return ApiResponse(data=ExpenseItemOut.model_validate(item))
+
+
+@router.post("/", response_model=ApiResponse[ExpenseItemOut])
+def create_expense_item(body: ExpenseItemCreate, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    data = body.model_dump(exclude_unset=True)
+    item = ExpenseItemService.create(db, data)
+    return ApiResponse(data=ExpenseItemOut.model_validate(item))
+
+
+@router.put("/{item_id}", response_model=ApiResponse[ExpenseItemOut])
+def update_expense_item(item_id: int, body: ExpenseItemUpdate, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    data = body.model_dump(exclude_unset=True)
+    item = ExpenseItemService.update(db, item_id, data)
+    if not item:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="费用项目不存在")
+    return ApiResponse(data=ExpenseItemOut.model_validate(item))
+
+
+@router.delete("/{item_id}", response_model=ApiResponse[None])
+def delete_expense_item(item_id: int, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    ExpenseItemService.soft_delete(db, item_id)
+    return ApiResponse(data=None)

+ 54 - 0
bx/bx-backend/app/routers/menu.py

@@ -0,0 +1,54 @@
+from typing import List
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.core.deps import require_roles
+from app.models.user import User
+from app.schemas.common import ApiResponse
+from app.schemas.menu import MenuCreate, MenuUpdate, MenuOut
+from app.services.menu import MenuService
+
+router = APIRouter(prefix="/api/menus", tags=["菜单管理"])
+
+
+@router.get("/tree", response_model=ApiResponse[List[MenuOut]])
+def get_menu_tree(db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    tree = MenuService.get_menu_tree(db)
+    return ApiResponse(data=[MenuOut.model_validate(m) for m in tree])
+
+
+@router.get("/role/{role_id}", response_model=ApiResponse[List[MenuOut]])
+def get_menus_by_role(role_id: int, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    menus = MenuService.get_menus_by_role(db, role_id)
+    return ApiResponse(data=[MenuOut.model_validate(m) for m in menus])
+
+
+@router.get("/{menu_id}", response_model=ApiResponse[MenuOut])
+def get_menu(menu_id: int, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    menu = MenuService.get_by_id(db, menu_id)
+    if not menu:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="菜单不存在")
+    return ApiResponse(data=MenuOut.model_validate(menu))
+
+
+@router.post("/", response_model=ApiResponse[MenuOut])
+def create_menu(body: MenuCreate, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    data = body.model_dump(exclude_unset=True)
+    menu = MenuService.create(db, data)
+    return ApiResponse(data=MenuOut.model_validate(menu))
+
+
+@router.put("/{menu_id}", response_model=ApiResponse[MenuOut])
+def update_menu(menu_id: int, body: MenuUpdate, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    data = body.model_dump(exclude_unset=True)
+    menu = MenuService.update(db, menu_id, data)
+    if not menu:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="菜单不存在")
+    return ApiResponse(data=MenuOut.model_validate(menu))
+
+
+@router.delete("/{menu_id}", response_model=ApiResponse[None])
+def delete_menu(menu_id: int, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    MenuService.soft_delete(db, menu_id)
+    return ApiResponse(data=None)

+ 102 - 0
bx/bx-backend/app/routers/payment.py

@@ -0,0 +1,102 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import or_
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.core.deps import require_roles
+from app.models.user import User
+from app.models.advance import AdvanceApply
+from app.models.reimburse import ReimburseApply
+from app.schemas.common import ApiResponse, PagedData
+from app.schemas.reimburse import ReimburseApplyOut, OffsetRequest
+from app.schemas.advance import AdvanceIssueOut, AdvanceIssueRequest
+from app.services.advance import AdvanceService
+from app.services.reimburse import ReimburseService
+
+router = APIRouter(prefix="/api/payments", tags=["支付处理"])
+
+
+@router.get("/pending", response_model=ApiResponse[PagedData])
+def list_pending_payments(
+    page: int = 1,
+    pageSize: int = 10,
+    biz_type: str | None = None,
+    keyword: str = "",
+    db: Session = Depends(get_db),
+    _: User = Depends(require_roles("FINANCE")),
+):
+    all_rows = []
+    if not biz_type or biz_type == "reimburse":
+        filters_r = [ReimburseApply.del_flag == 0, ReimburseApply.status == "approved"]
+        if keyword:
+            filters_r.append(or_(ReimburseApply.apply_code.contains(keyword), ReimburseApply.applicant_name.contains(keyword)))
+        rows_r, total_r = ReimburseService.get_paged(db, page=page, page_size=pageSize, filters=filters_r)
+        for r in rows_r:
+            all_rows.append({"type": "reimburse", "data": ReimburseApplyOut.model_validate(r)})
+    if not biz_type or biz_type == "advance":
+        filters_a = [AdvanceApply.del_flag == 0, AdvanceApply.status == "approved", AdvanceApply.issue_status == None]
+        if keyword:
+            filters_a.append(or_(AdvanceApply.apply_code.contains(keyword), AdvanceApply.applicant_name.contains(keyword)))
+        rows_a, total_a = AdvanceService.get_paged(db, page=page, page_size=pageSize, filters=filters_a)
+        for r in rows_a:
+            all_rows.append({"type": "advance", "data": AdvanceIssueOut.model_validate(r)})
+    paged_data = PagedData(rows=all_rows, total=len(all_rows), page=page, page_size=pageSize)
+    return ApiResponse(data=paged_data)
+
+
+@router.post("/{id}/pay", response_model=ApiResponse[ReimburseApplyOut])
+def pay(
+    id: int,
+    db: Session = Depends(get_db),
+    current_user: User = Depends(require_roles("FINANCE")),
+):
+    reimburse = ReimburseService.pay(db, id, current_user)
+    if not reimburse:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="支付记录不存在")
+    return ApiResponse(data=ReimburseApplyOut.model_validate(reimburse))
+
+
+@router.post("/{id}/offset", response_model=ApiResponse[ReimburseApplyOut])
+def offset(
+    id: int,
+    body: OffsetRequest,
+    db: Session = Depends(get_db),
+    current_user: User = Depends(require_roles("FINANCE")),
+):
+    reimburse = ReimburseService.offset(db, id, body.advance_id, body.offset_amount, current_user)
+    if not reimburse:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="支付记录不存在")
+    return ApiResponse(data=ReimburseApplyOut.model_validate(reimburse))
+
+
+@router.get("/advance-issues", response_model=ApiResponse[PagedData[AdvanceIssueOut]])
+def list_advance_issues(
+    page: int = 1,
+    pageSize: int = 10,
+    issue_status: str | None = None,
+    keyword: str = "",
+    db: Session = Depends(get_db),
+    _: User = Depends(require_roles("FINANCE")),
+):
+    filters = [AdvanceApply.del_flag == 0, AdvanceApply.status == "approved"]
+    if issue_status:
+        filters.append(AdvanceApply.issue_status == issue_status)
+    if keyword:
+        filters.append(or_(AdvanceApply.apply_code.contains(keyword), AdvanceApply.applicant_name.contains(keyword)))
+    rows, total = AdvanceService.get_paged(db, page=page, page_size=pageSize, filters=filters)
+    out_rows = [AdvanceIssueOut.model_validate(r) for r in rows]
+    paged_data = PagedData(rows=out_rows, total=total, page=page, page_size=pageSize)
+    return ApiResponse(data=paged_data)
+
+
+@router.post("/advance/{advance_id}/issue", response_model=ApiResponse[AdvanceIssueOut])
+def issue_advance(
+    advance_id: int,
+    body: AdvanceIssueRequest,
+    db: Session = Depends(get_db),
+    current_user: User = Depends(require_roles("FINANCE")),
+):
+    advance = AdvanceService.issue(db, advance_id, current_user, body.issue_remark or "")
+    if not advance:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
+    return ApiResponse(data=AdvanceIssueOut.model_validate(advance))

+ 133 - 0
bx/bx-backend/app/routers/reimburse.py

@@ -0,0 +1,133 @@
+from typing import List
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import or_
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.core.deps import get_current_user
+from app.models.user import User
+from app.models.reimburse import ReimburseApply
+from app.schemas.common import ApiResponse, PagedData
+from app.schemas.reimburse import ReimburseApplyCreate, ReimburseApplyUpdate, ReimburseApplyOut, ReimburseApprovalCreate, ReimburseApprovalOut
+from app.services.reimburse import ReimburseService
+from app.services.expense_item import ExpenseItemService
+from app.models.workflow import WorkflowDef
+from app.utils.codegen import generate_apply_code
+
+router = APIRouter(prefix="/api/reimburses", tags=["报销管理"])
+
+
+@router.get("/", response_model=ApiResponse[PagedData[ReimburseApplyOut]])
+def list_reimburses(
+    page: int = 1,
+    pageSize: int = 10,
+    status: str | None = None,
+    keyword: str = "",
+    expense_id: int | None = None,
+    db: Session = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+):
+    filters = [ReimburseApply.del_flag == 0]
+    if status:
+        filters.append(ReimburseApply.status == status)
+    if keyword:
+        filters.append(or_(ReimburseApply.apply_code.contains(keyword), ReimburseApply.applicant_name.contains(keyword)))
+    if expense_id:
+        filters.append(ReimburseApply.expense_id == expense_id)
+    rows, total = ReimburseService.get_paged(db, page=page, page_size=pageSize, filters=filters)
+    out_rows = [ReimburseApplyOut.model_validate(r) for r in rows]
+    paged_data = PagedData(rows=out_rows, total=total, page=page, page_size=pageSize)
+    return ApiResponse(data=paged_data)
+
+
+@router.get("/my", response_model=ApiResponse[PagedData[ReimburseApplyOut]])
+def my_reimburses(
+    page: int = 1,
+    pageSize: int = 10,
+    status: str | None = None,
+    keyword: str = "",
+    db: Session = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+):
+    filters = [ReimburseApply.del_flag == 0, ReimburseApply.applicant_id == current_user.id]
+    if status:
+        filters.append(ReimburseApply.status == status)
+    if keyword:
+        filters.append(or_(ReimburseApply.apply_code.contains(keyword), ReimburseApply.applicant_name.contains(keyword)))
+    rows, total = ReimburseService.get_paged(db, page=page, page_size=pageSize, filters=filters)
+    out_rows = [ReimburseApplyOut.model_validate(r) for r in rows]
+    paged_data = PagedData(rows=out_rows, total=total, page=page, page_size=pageSize)
+    return ApiResponse(data=paged_data)
+
+
+@router.get("/{reimburse_id}", response_model=ApiResponse[ReimburseApplyOut])
+def get_reimburse(reimburse_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
+    reimburse = ReimburseService.get_by_id(db, reimburse_id)
+    if not reimburse:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
+    return ApiResponse(data=ReimburseApplyOut.model_validate(reimburse))
+
+
+@router.get("/{reimburse_id}/approvals", response_model=ApiResponse[List[ReimburseApprovalOut]])
+def get_approvals(reimburse_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
+    approvals = ReimburseService.get_approvals(db, reimburse_id)
+    return ApiResponse(data=[ReimburseApprovalOut.model_validate(a) for a in approvals])
+
+
+@router.post("/", response_model=ApiResponse[ReimburseApplyOut])
+def create_reimburse(body: ReimburseApplyCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
+    data = body.model_dump(exclude_unset=True)
+    data["applicant_id"] = current_user.id
+    data["applicant_name"] = current_user.real_name or current_user.username
+    data["apply_code"] = generate_apply_code(db, "BX")
+    data["status"] = "draft"
+    wf = db.query(WorkflowDef).filter(WorkflowDef.biz_type == "reimburse", WorkflowDef.del_flag == 0).first()
+    if wf:
+        data["wf_id"] = wf.id
+    if "expense_item_id" in data:
+        eid = data.pop("expense_item_id")
+        data["expense_id"] = eid
+        item = ExpenseItemService.get_by_id(db, eid)
+        if item:
+            data["expense_name"] = item.item_name
+    reimburse = ReimburseService.create(db, data)
+    return ApiResponse(data=ReimburseApplyOut.model_validate(reimburse))
+
+
+@router.put("/{reimburse_id}", response_model=ApiResponse[ReimburseApplyOut])
+def update_reimburse(reimburse_id: int, body: ReimburseApplyUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
+    data = body.model_dump(exclude_unset=True)
+    reimburse = ReimburseService.update(db, reimburse_id, data)
+    if not reimburse:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
+    return ApiResponse(data=ReimburseApplyOut.model_validate(reimburse))
+
+
+@router.post("/{reimburse_id}/submit", response_model=ApiResponse[ReimburseApplyOut])
+def submit_reimburse(reimburse_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
+    reimburse = ReimburseService.submit(db, reimburse_id, current_user)
+    if not reimburse:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
+    return ApiResponse(data=ReimburseApplyOut.model_validate(reimburse))
+
+
+@router.post("/{reimburse_id}/approve", response_model=ApiResponse[ReimburseApplyOut])
+def approve_reimburse(reimburse_id: int, body: ReimburseApprovalCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
+    reimburse = ReimburseService.approve(db, reimburse_id, current_user, body.approval_opinion or "")
+    if not reimburse:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
+    return ApiResponse(data=ReimburseApplyOut.model_validate(reimburse))
+
+
+@router.post("/{reimburse_id}/reject", response_model=ApiResponse[ReimburseApplyOut])
+def reject_reimburse(reimburse_id: int, body: ReimburseApprovalCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
+    reimburse = ReimburseService.reject(db, reimburse_id, current_user, body.approval_opinion or "")
+    if not reimburse:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
+    return ApiResponse(data=ReimburseApplyOut.model_validate(reimburse))
+
+
+@router.delete("/{reimburse_id}", response_model=ApiResponse[None])
+def delete_reimburse(reimburse_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
+    ReimburseService.soft_delete(db, reimburse_id)
+    return ApiResponse(data=None)

+ 60 - 0
bx/bx-backend/app/routers/role.py

@@ -0,0 +1,60 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import or_
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.core.deps import require_roles
+from app.models.user import User
+from app.models.role import Role
+from app.schemas.common import ApiResponse, PagedData
+from app.schemas.role import RoleCreate, RoleUpdate, RoleOut
+from app.services.role import RoleService
+
+router = APIRouter(prefix="/api/roles", tags=["角色管理"])
+
+
+@router.get("/", response_model=ApiResponse[PagedData[RoleOut]])
+def list_roles(
+    page: int = 1,
+    pageSize: int = 10,
+    keyword: str = "",
+    db: Session = Depends(get_db),
+    _: User = Depends(require_roles("ADMIN")),
+):
+    filters = [Role.del_flag == 0]
+    if keyword:
+        filters.append(or_(Role.role_name.contains(keyword), Role.role_code.contains(keyword)))
+    rows, total = RoleService.get_paged(db, page=page, page_size=pageSize, filters=filters)
+    out_rows = [RoleOut.model_validate(r) for r in rows]
+    paged_data = PagedData(rows=out_rows, total=total, page=page, page_size=pageSize)
+    return ApiResponse(data=paged_data)
+
+
+@router.get("/{role_id}", response_model=ApiResponse[RoleOut])
+def get_role(role_id: int, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    role = RoleService.get_by_id(db, role_id)
+    if not role:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="角色不存在")
+    return ApiResponse(data=RoleOut.model_validate(role))
+
+
+@router.post("/", response_model=ApiResponse[RoleOut])
+def create_role(body: RoleCreate, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    data = body.model_dump(exclude_unset=True)
+    role = RoleService.create(db, data)
+    return ApiResponse(data=RoleOut.model_validate(role))
+
+
+@router.put("/{role_id}", response_model=ApiResponse[RoleOut])
+def update_role(role_id: int, body: RoleUpdate, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    data = body.model_dump(exclude_unset=True)
+    role = RoleService.update(db, role_id, data)
+    if not role:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="角色不存在")
+    return ApiResponse(data=RoleOut.model_validate(role))
+
+
+@router.delete("/{role_id}", response_model=ApiResponse[None])
+def delete_role(role_id: int, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    RoleService.soft_delete(db, role_id)
+    return ApiResponse(data=None)

+ 62 - 0
bx/bx-backend/app/routers/user.py

@@ -0,0 +1,62 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import or_
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.core.deps import require_roles
+from app.models.user import User
+from app.schemas.common import ApiResponse, PagedData
+from app.schemas.user import UserCreate, UserUpdate, UserOut
+from app.services.user import UserService
+
+router = APIRouter(prefix="/api/users", tags=["用户管理"])
+
+
+@router.get("/", response_model=ApiResponse[PagedData[UserOut]])
+def list_users(
+    page: int = 1,
+    pageSize: int = 10,
+    keyword: str = "",
+    status: int | None = None,
+    db: Session = Depends(get_db),
+    _: User = Depends(require_roles("ADMIN")),
+):
+    filters = [User.del_flag == 0]
+    if status is not None:
+        filters.append(User.status == status)
+    if keyword:
+        filters.append(or_(User.username.contains(keyword), User.real_name.contains(keyword)))
+    rows, total = UserService.get_paged(db, page=page, page_size=pageSize, filters=filters)
+    out_rows = [UserOut.model_validate(r) for r in rows]
+    paged_data = PagedData(rows=out_rows, total=total, page=page, page_size=pageSize)
+    return ApiResponse(data=paged_data)
+
+
+@router.get("/{user_id}", response_model=ApiResponse[UserOut])
+def get_user(user_id: int, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    user = UserService.get_by_id(db, user_id)
+    if not user:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
+    return ApiResponse(data=UserOut.model_validate(user))
+
+
+@router.post("/", response_model=ApiResponse[UserOut])
+def create_user(body: UserCreate, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    data = body.model_dump(exclude_unset=True)
+    user = UserService.create(db, data)
+    return ApiResponse(data=UserOut.model_validate(user))
+
+
+@router.put("/{user_id}", response_model=ApiResponse[UserOut])
+def update_user(user_id: int, body: UserUpdate, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    data = body.model_dump(exclude_unset=True)
+    user = UserService.update(db, user_id, data)
+    if not user:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
+    return ApiResponse(data=UserOut.model_validate(user))
+
+
+@router.delete("/{user_id}", response_model=ApiResponse[None])
+def delete_user(user_id: int, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    UserService.soft_delete(db, user_id)
+    return ApiResponse(data=None)

+ 88 - 0
bx/bx-backend/app/routers/workflow.py

@@ -0,0 +1,88 @@
+from typing import List
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.core.deps import require_roles
+from app.models.user import User
+from app.schemas.common import ApiResponse, PagedData
+from app.schemas.workflow import WorkflowDefCreate, WorkflowDefUpdate, WorkflowDefOut, WorkflowNodeCreate, WorkflowNodeOut
+from app.services.workflow import WorkflowService
+
+router = APIRouter(prefix="/api/workflows", tags=["工作流配置"])
+
+
+@router.get("/", response_model=ApiResponse[PagedData[WorkflowDefOut]])
+def list_workflows(
+    page: int = 1,
+    pageSize: int = 10,
+    db: Session = Depends(get_db),
+    _: User = Depends(require_roles("ADMIN")),
+):
+    rows, total = WorkflowService.get_paged(db, page=page, page_size=pageSize)
+    out_rows = [WorkflowDefOut.model_validate(r) for r in rows]
+    paged_data = PagedData(rows=out_rows, total=total, page=page, page_size=pageSize)
+    return ApiResponse(data=paged_data)
+
+
+@router.get("/biz/{biz_type}", response_model=ApiResponse[WorkflowDefOut])
+def get_workflow_by_biz_type(biz_type: str, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    workflow = WorkflowService.get_by_biz_type(db, biz_type)
+    if not workflow:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="工作流不存在")
+    return ApiResponse(data=WorkflowDefOut.model_validate(workflow))
+
+
+@router.get("/{workflow_id}", response_model=ApiResponse[WorkflowDefOut])
+def get_workflow(workflow_id: int, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    workflow = WorkflowService.get_by_id(db, workflow_id)
+    if not workflow:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="工作流不存在")
+    return ApiResponse(data=WorkflowDefOut.model_validate(workflow))
+
+
+@router.post("/", response_model=ApiResponse[WorkflowDefOut])
+def create_workflow(body: WorkflowDefCreate, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    data = body.model_dump(exclude_unset=True)
+    nodes_data = data.pop("nodes", [])
+    workflow = WorkflowService.create(db, data)
+    if nodes_data:
+        for n in nodes_data:
+            n["wf_id"] = workflow.id
+        WorkflowService.save_nodes(db, workflow.id, nodes_data)
+        db.refresh(workflow)
+    return ApiResponse(data=WorkflowDefOut.model_validate(workflow))
+
+
+@router.put("/{workflow_id}", response_model=ApiResponse[WorkflowDefOut])
+def update_workflow(workflow_id: int, body: WorkflowDefUpdate, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    data = body.model_dump(exclude_unset=True)
+    nodes_data = data.pop("nodes", None)
+    workflow = WorkflowService.update(db, workflow_id, data)
+    if not workflow:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="工作流不存在")
+    if nodes_data is not None:
+        WorkflowService.save_nodes(db, workflow_id, nodes_data)
+        db.refresh(workflow)
+    return ApiResponse(data=WorkflowDefOut.model_validate(workflow))
+
+
+@router.put("/{workflow_id}/nodes", response_model=ApiResponse[WorkflowDefOut])
+def save_workflow_nodes(
+    workflow_id: int,
+    body: List[WorkflowNodeCreate],
+    db: Session = Depends(get_db),
+    _: User = Depends(require_roles("ADMIN")),
+):
+    nodes_data = [n.model_dump(exclude_unset=True) for n in body]
+    WorkflowService.save_nodes(db, workflow_id, nodes_data)
+    workflow = WorkflowService.get_by_id(db, workflow_id)
+    if not workflow:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="工作流不存在")
+    return ApiResponse(data=WorkflowDefOut.model_validate(workflow))
+
+
+@router.delete("/{workflow_id}", response_model=ApiResponse[None])
+def delete_workflow(workflow_id: int, db: Session = Depends(get_db), _: User = Depends(require_roles("ADMIN"))):
+    WorkflowService.soft_delete(db, workflow_id)
+    return ApiResponse(data=None)

+ 63 - 0
bx/bx-backend/app/schemas/__init__.py

@@ -0,0 +1,63 @@
+from app.schemas.common import (
+    ApiResponse,
+    DatetimeToStr,
+    OptDatetimeToStr,
+    PagedData,
+    PagedQuery,
+    PagedResponse,
+    TokenResponse,
+)
+from app.schemas.user import (
+    UserCreate,
+    UserListOut,
+    UserLogin,
+    UserOut,
+    UserUpdate,
+)
+from app.schemas.role import (
+    RoleCreate,
+    RoleOut,
+    RoleUpdate,
+)
+from app.schemas.menu import (
+    MenuCreate,
+    MenuOut,
+    MenuTreeOut,
+    MenuUpdate,
+)
+from app.schemas.expense_item import (
+    ExpenseItemCreate,
+    ExpenseItemOut,
+    ExpenseItemTreeOut,
+    ExpenseItemUpdate,
+)
+from app.schemas.workflow import (
+    WorkflowDefCreate,
+    WorkflowDefOut,
+    WorkflowDefUpdate,
+    WorkflowNodeCreate,
+    WorkflowNodeOut,
+    WorkflowNodeUpdate,
+)
+from app.schemas.advance import (
+    AdvanceApplyCreate,
+    AdvanceApplyOut,
+    AdvanceApplyUpdate,
+    AdvanceApprovalCreate,
+    AdvanceApprovalOut,
+    AdvanceIssueOut,
+    AdvanceIssueRequest,
+)
+from app.schemas.reimburse import (
+    OffsetRequest,
+    PaymentRequest,
+    ReimburseApplyCreate,
+    ReimburseApplyOut,
+    ReimburseApplyUpdate,
+    ReimburseApprovalCreate,
+    ReimburseApprovalOut,
+)
+from app.schemas.attachment import (
+    AttachmentOut,
+    AttachmentUploadResponse,
+)

BIN
bx/bx-backend/app/schemas/__pycache__/__init__.cpython-311.pyc


BIN
bx/bx-backend/app/schemas/__pycache__/advance.cpython-311.pyc


BIN
bx/bx-backend/app/schemas/__pycache__/attachment.cpython-311.pyc


BIN
bx/bx-backend/app/schemas/__pycache__/common.cpython-311.pyc


BIN
bx/bx-backend/app/schemas/__pycache__/expense_item.cpython-311.pyc


BIN
bx/bx-backend/app/schemas/__pycache__/menu.cpython-311.pyc


BIN
bx/bx-backend/app/schemas/__pycache__/reimburse.cpython-311.pyc


BIN
bx/bx-backend/app/schemas/__pycache__/role.cpython-311.pyc


BIN
bx/bx-backend/app/schemas/__pycache__/user.cpython-311.pyc


BIN
bx/bx-backend/app/schemas/__pycache__/workflow.cpython-311.pyc


+ 74 - 0
bx/bx-backend/app/schemas/advance.py

@@ -0,0 +1,74 @@
+from typing import Optional
+
+from pydantic import BaseModel, ConfigDict, Field
+from pydantic.alias_generators import to_camel
+
+from app.schemas.common import DatetimeToStr, OptDatetimeToStr
+
+
+class AdvanceApplyCreate(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    reason: str
+    purpose: Optional[str] = None
+    amount: float
+
+
+class AdvanceApplyUpdate(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    reason: Optional[str] = None
+    purpose: Optional[str] = None
+    amount: Optional[float] = None
+
+
+class AdvanceApplyOut(BaseModel):
+    model_config = ConfigDict(from_attributes=True, populate_by_name=True, alias_generator=to_camel)
+
+    id: int
+    apply_code: str
+    applicant_name: str
+    reason: str
+    purpose: Optional[str] = None
+    amount: float
+    status: str
+    current_node: Optional[str] = None
+    apply_time: DatetimeToStr = Field(validation_alias="create_time")
+
+
+class AdvanceApprovalCreate(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    approval_status: str
+    approval_opinion: Optional[str] = None
+
+
+class AdvanceApprovalOut(BaseModel):
+    model_config = ConfigDict(from_attributes=True, populate_by_name=True, alias_generator=to_camel)
+
+    id: int
+    advance_id: int
+    approval_status: str
+    approval_opinion: Optional[str] = None
+    approver_name: str
+    approval_time: DatetimeToStr
+
+
+class AdvanceIssueOut(BaseModel):
+    model_config = ConfigDict(from_attributes=True, populate_by_name=True, alias_generator=to_camel)
+
+    id: int
+    apply_code: str
+    applicant_name: str
+    reason: str
+    amount: float
+    approve_time: DatetimeToStr = Field(validation_alias="update_time")
+    issue_status: Optional[str] = None
+    issue_time: OptDatetimeToStr
+    issue_user_name: Optional[str] = None
+
+
+class AdvanceIssueRequest(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    issue_remark: Optional[str] = None

+ 29 - 0
bx/bx-backend/app/schemas/attachment.py

@@ -0,0 +1,29 @@
+from typing import Optional
+
+from pydantic import BaseModel, ConfigDict
+from pydantic.alias_generators import to_camel
+
+from app.schemas.common import DatetimeToStr
+
+
+class AttachmentOut(BaseModel):
+    model_config = ConfigDict(from_attributes=True, populate_by_name=True, alias_generator=to_camel)
+
+    id: int
+    file_name: str
+    file_path: str
+    file_size: int
+    file_type: Optional[str] = None
+    biz_type: Optional[str] = None
+    biz_id: Optional[int] = None
+    create_time: DatetimeToStr
+
+
+class AttachmentUploadResponse(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    id: int
+    file_name: str
+    file_path: str
+    file_size: int
+    url: Optional[str] = None

+ 52 - 0
bx/bx-backend/app/schemas/common.py

@@ -0,0 +1,52 @@
+from typing import Annotated, Generic, List, Optional, TypeVar
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict, PlainSerializer
+from pydantic.alias_generators import to_camel
+
+T = TypeVar("T")
+
+
+def _dt_to_str(v: Optional[datetime]) -> Optional[str]:
+    if v is None:
+        return None
+    return v.strftime("%Y-%m-%d %H:%M:%S")
+
+
+DatetimeToStr = Annotated[datetime, PlainSerializer(_dt_to_str, return_type=str)]
+OptDatetimeToStr = Annotated[Optional[datetime], PlainSerializer(_dt_to_str, return_type=Optional[str])]
+
+
+class PagedQuery(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    page: int = 1
+    page_size: int = 10
+
+
+class ApiResponse(BaseModel, Generic[T]):
+    code: int = 0
+    msg: str = "success"
+    data: Optional[T] = None
+
+
+class PagedData(BaseModel, Generic[T]):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    rows: List[T]
+    total: int
+    page: int
+    page_size: int
+
+
+class PagedResponse(BaseModel, Generic[T]):
+    code: int = 0
+    msg: str = "success"
+    data: Optional[PagedData[T]] = None
+
+
+class TokenResponse(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    access_token: str
+    token_type: str = "bearer"

+ 47 - 0
bx/bx-backend/app/schemas/expense_item.py

@@ -0,0 +1,47 @@
+from typing import Optional, List
+
+from pydantic import BaseModel, ConfigDict
+from pydantic.alias_generators import to_camel
+
+
+class ExpenseItemCreate(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    parent_id: int = 0
+    item_name: str
+    item_code: str
+    level: int = 1
+    sort_order: int = 0
+    status: int = 1
+
+
+class ExpenseItemUpdate(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    parent_id: Optional[int] = None
+    item_name: Optional[str] = None
+    item_code: Optional[str] = None
+    level: Optional[int] = None
+    sort_order: Optional[int] = None
+    status: Optional[int] = None
+
+
+class ExpenseItemOut(BaseModel):
+    model_config = ConfigDict(from_attributes=True, populate_by_name=True, alias_generator=to_camel)
+
+    id: int
+    parent_id: int
+    item_name: str
+    item_code: str
+    level: int
+    sort_order: int
+    status: int
+    child_count: int = 0
+    children: Optional[List["ExpenseItemOut"]] = None
+
+
+class ExpenseItemTreeOut(ExpenseItemOut):
+    pass
+
+
+ExpenseItemOut.model_rebuild()

+ 49 - 0
bx/bx-backend/app/schemas/menu.py

@@ -0,0 +1,49 @@
+from typing import Optional, List
+
+from pydantic import BaseModel, ConfigDict
+from pydantic.alias_generators import to_camel
+
+
+class MenuCreate(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    parent_id: int = 0
+    menu_name: str
+    menu_code: Optional[str] = None
+    menu_type: int
+    path: Optional[str] = None
+    sort_order: int = 0
+    status: int = 1
+
+
+class MenuUpdate(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    parent_id: Optional[int] = None
+    menu_name: Optional[str] = None
+    menu_code: Optional[str] = None
+    menu_type: Optional[int] = None
+    path: Optional[str] = None
+    sort_order: Optional[int] = None
+    status: Optional[int] = None
+
+
+class MenuOut(BaseModel):
+    model_config = ConfigDict(from_attributes=True, populate_by_name=True, alias_generator=to_camel)
+
+    id: int
+    parent_id: int
+    menu_name: str
+    menu_code: Optional[str] = None
+    menu_type: int
+    path: Optional[str] = None
+    sort_order: int
+    status: int
+    children: Optional[List["MenuOut"]] = None
+
+
+class MenuTreeOut(MenuOut):
+    pass
+
+
+MenuOut.model_rebuild()

+ 80 - 0
bx/bx-backend/app/schemas/reimburse.py

@@ -0,0 +1,80 @@
+from typing import Optional
+
+from pydantic import BaseModel, ConfigDict, Field
+from pydantic.alias_generators import to_camel
+
+from app.schemas.common import DatetimeToStr
+
+
+class ReimburseApplyCreate(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    title: str
+    expense_item_id: int
+    amount: float
+    invoice_count: int = 0
+    attach_count: int = 0
+    advance_id: Optional[int] = None
+
+
+class ReimburseApplyUpdate(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    title: Optional[str] = None
+    expense_item_id: Optional[int] = None
+    amount: Optional[float] = None
+    invoice_count: Optional[int] = None
+    attach_count: Optional[int] = None
+    advance_id: Optional[int] = None
+
+
+class ReimburseApplyOut(BaseModel):
+    model_config = ConfigDict(from_attributes=True, populate_by_name=True, alias_generator=to_camel)
+
+    id: int
+    apply_code: str
+    applicant_name: str
+    title: str
+    expense_name: str
+    amount: float
+    current_node: Optional[str] = None
+    apply_time: DatetimeToStr = Field(validation_alias="create_time")
+    status: Optional[str] = None
+    invoice_count: int = 0
+    attach_count: int = 0
+
+
+class ReimburseApprovalCreate(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    approval_status: str
+    approval_opinion: Optional[str] = None
+
+
+class ReimburseApprovalOut(BaseModel):
+    model_config = ConfigDict(from_attributes=True, populate_by_name=True, alias_generator=to_camel)
+
+    id: int
+    reimburse_id: int
+    approval_status: str
+    approval_opinion: Optional[str] = None
+    approver_name: str
+    approval_time: DatetimeToStr
+
+
+class PaymentRequest(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    code: str
+    type: str
+    action: str
+    offset_advance_id: Optional[int] = None
+    offset_amount: Optional[float] = None
+
+
+class OffsetRequest(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    reimburse_id: int
+    advance_id: int
+    offset_amount: float

+ 39 - 0
bx/bx-backend/app/schemas/role.py

@@ -0,0 +1,39 @@
+from typing import Optional
+
+from pydantic import BaseModel, ConfigDict
+from pydantic.alias_generators import to_camel
+
+from app.schemas.common import DatetimeToStr
+
+
+class RoleCreate(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    role_name: str
+    role_code: str
+    sort_order: int = 0
+    status: int = 1
+    remark: Optional[str] = None
+
+
+class RoleUpdate(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    role_name: Optional[str] = None
+    role_code: Optional[str] = None
+    sort_order: Optional[int] = None
+    status: Optional[int] = None
+    remark: Optional[str] = None
+
+
+class RoleOut(BaseModel):
+    model_config = ConfigDict(from_attributes=True, populate_by_name=True, alias_generator=to_camel)
+
+    id: int
+    role_name: str
+    role_code: str
+    sort_order: int
+    status: int
+    user_count: int = 0
+    remark: Optional[str] = None
+    create_time: DatetimeToStr

+ 85 - 0
bx/bx-backend/app/schemas/user.py

@@ -0,0 +1,85 @@
+from typing import Optional, List
+
+from pydantic import BaseModel, ConfigDict, field_validator, model_validator
+from pydantic.alias_generators import to_camel
+
+from app.schemas.common import DatetimeToStr
+
+
+class UserCreate(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    username: str
+    real_name: str
+    phone: Optional[str] = None
+    email: Optional[str] = None
+    status: int = 1
+    password: str
+    role_ids: Optional[List[int]] = None
+
+
+class UserUpdate(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    real_name: Optional[str] = None
+    phone: Optional[str] = None
+    email: Optional[str] = None
+    status: Optional[int] = None
+    password: Optional[str] = None
+    role_ids: Optional[List[int]] = None
+
+
+class RoleBrief(BaseModel):
+    model_config = ConfigDict(from_attributes=True, populate_by_name=True, alias_generator=to_camel)
+
+    id: int
+    role_name: str
+    role_code: str
+
+
+class UserOut(BaseModel):
+    model_config = ConfigDict(from_attributes=True, populate_by_name=True, alias_generator=to_camel)
+
+    id: int
+    username: str
+    real_name: Optional[str] = None
+    phone: Optional[str] = None
+    email: Optional[str] = None
+    status: int
+    roles: str = ""
+    role_list: List[RoleBrief] = []
+    create_time: DatetimeToStr
+
+    @model_validator(mode="before")
+    @classmethod
+    def extract_roles(cls, values):
+        if isinstance(values, dict):
+            return values
+        raw_roles = getattr(values, "roles", None)
+        role_list_data = []
+        names = []
+        if raw_roles is not None and hasattr(raw_roles, "__iter__"):
+            for r in raw_roles:
+                role_list_data.append({"id": r.id, "role_name": r.role_name, "role_code": r.role_code})
+                names.append(r.role_name)
+        data = {
+            "id": values.id,
+            "username": values.username,
+            "real_name": values.real_name,
+            "phone": values.phone,
+            "email": values.email,
+            "status": values.status,
+            "roles": ",".join(names) if names else "",
+            "role_list": role_list_data,
+            "create_time": values.create_time,
+        }
+        return data
+
+
+class UserLogin(BaseModel):
+    username: str
+    password: str
+
+
+class UserListOut(UserOut):
+    pass

+ 57 - 0
bx/bx-backend/app/schemas/workflow.py

@@ -0,0 +1,57 @@
+from typing import Optional, List
+
+from pydantic import BaseModel, ConfigDict
+from pydantic.alias_generators import to_camel
+
+
+class WorkflowNodeCreate(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    node_name: str
+    node_order: int
+    approver_id: int
+
+
+class WorkflowNodeUpdate(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    node_name: Optional[str] = None
+    node_order: Optional[int] = None
+    approver_id: Optional[int] = None
+
+
+class WorkflowNodeOut(BaseModel):
+    model_config = ConfigDict(from_attributes=True, populate_by_name=True, alias_generator=to_camel)
+
+    id: int
+    node_name: str
+    node_order: int
+    approver_name: Optional[str] = None
+
+
+class WorkflowDefCreate(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    biz_type: str
+    wf_name: str
+    status: int = 1
+    nodes: Optional[List[WorkflowNodeCreate]] = None
+
+
+class WorkflowDefUpdate(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
+
+    biz_type: Optional[str] = None
+    wf_name: Optional[str] = None
+    status: Optional[int] = None
+    nodes: Optional[List[WorkflowNodeCreate]] = None
+
+
+class WorkflowDefOut(BaseModel):
+    model_config = ConfigDict(from_attributes=True, populate_by_name=True, alias_generator=to_camel)
+
+    id: int
+    biz_type: str
+    wf_name: str
+    status: int
+    nodes: List[WorkflowNodeOut] = []

+ 0 - 0
bx/bx-backend/app/services/__init__.py


BIN
bx/bx-backend/app/services/__pycache__/__init__.cpython-311.pyc


BIN
bx/bx-backend/app/services/__pycache__/advance.cpython-311.pyc


BIN
bx/bx-backend/app/services/__pycache__/attachment.cpython-311.pyc


BIN
bx/bx-backend/app/services/__pycache__/base.cpython-311.pyc


BIN
bx/bx-backend/app/services/__pycache__/expense_item.cpython-311.pyc


BIN
bx/bx-backend/app/services/__pycache__/menu.cpython-311.pyc


BIN
bx/bx-backend/app/services/__pycache__/minio.cpython-311.pyc


BIN
bx/bx-backend/app/services/__pycache__/reimburse.cpython-311.pyc


BIN
bx/bx-backend/app/services/__pycache__/role.cpython-311.pyc


BIN
bx/bx-backend/app/services/__pycache__/user.cpython-311.pyc


BIN
bx/bx-backend/app/services/__pycache__/workflow.cpython-311.pyc


+ 186 - 0
bx/bx-backend/app/services/advance.py

@@ -0,0 +1,186 @@
+from datetime import datetime
+
+from sqlalchemy.orm import Session
+
+from app.models.advance import AdvanceApply, AdvanceApproval
+from app.models.workflow import WorkflowDef, WorkflowNode
+from app.services.base import CrudService
+from app.utils.codegen import generate_apply_code
+
+
+class AdvanceService:
+
+    @staticmethod
+    def get_by_id(db: Session, id: int) -> AdvanceApply | None:
+        return CrudService.get_by_id(AdvanceApply, db, id)
+
+    @staticmethod
+    def get_paged(db: Session, page: int, page_size: int, filters: list | None = None):
+        return CrudService.get_paged(AdvanceApply, db, page, page_size, filters)
+
+    @staticmethod
+    def create(db: Session, obj_in: dict) -> AdvanceApply:
+        return CrudService.create(AdvanceApply, db, obj_in)
+
+    @staticmethod
+    def update(db: Session, id: int, obj_in: dict) -> AdvanceApply | None:
+        return CrudService.update(AdvanceApply, db, id, obj_in)
+
+    @staticmethod
+    def soft_delete(db: Session, id: int) -> bool:
+        return CrudService.soft_delete(AdvanceApply, db, id)
+
+    @staticmethod
+    def submit(db: Session, apply_id: int, user) -> AdvanceApply | None:
+        apply_obj = (
+            db.query(AdvanceApply)
+            .filter(AdvanceApply.id == apply_id, AdvanceApply.del_flag == 0)
+            .first()
+        )
+        if not apply_obj:
+            return None
+        apply_obj.status = "pending"
+        if not apply_obj.apply_code:
+            apply_obj.apply_code = generate_apply_code(db, "BYJ")
+        apply_obj.update_by = str(user.id)
+        wf = (
+            db.query(WorkflowDef)
+            .filter(WorkflowDef.id == apply_obj.wf_id, WorkflowDef.del_flag == 0)
+            .first()
+        )
+        if wf:
+            nodes = (
+                db.query(WorkflowNode)
+                .filter(WorkflowNode.wf_id == wf.id)
+                .order_by(WorkflowNode.node_order)
+                .all()
+            )
+            if nodes:
+                apply_obj.current_node = nodes[0].node_name
+        db.flush()
+        return apply_obj
+
+    @staticmethod
+    def approve(db: Session, apply_id: int, approver, opinion: str) -> AdvanceApply | None:
+        apply_obj = (
+            db.query(AdvanceApply)
+            .filter(AdvanceApply.id == apply_id, AdvanceApply.del_flag == 0)
+            .first()
+        )
+        if not apply_obj:
+            return None
+        wf = (
+            db.query(WorkflowDef)
+            .filter(WorkflowDef.id == apply_obj.wf_id, WorkflowDef.del_flag == 0)
+            .first()
+        )
+        if not wf:
+            return None
+        nodes = (
+            db.query(WorkflowNode)
+            .filter(WorkflowNode.wf_id == wf.id)
+            .order_by(WorkflowNode.node_order)
+            .all()
+        )
+        current_idx = None
+        for i, node in enumerate(nodes):
+            if node.node_name == apply_obj.current_node:
+                current_idx = i
+                break
+        if current_idx is None:
+            return None
+        current_node = nodes[current_idx]
+        if current_node.approver_id != approver.id:
+            return None
+        approval = AdvanceApproval(
+            apply_id=apply_id,
+            approver_id=approver.id,
+            approver_name=approver.username,
+            approval_opinion=opinion,
+            node_name=current_node.node_name,
+            node_order=current_node.node_order,
+            approval_status="approved",
+            approval_time=datetime.now(),
+        )
+        db.add(approval)
+        if current_idx == len(nodes) - 1:
+            apply_obj.status = "approved"
+            apply_obj.current_node = None
+        else:
+            apply_obj.current_node = nodes[current_idx + 1].node_name
+        apply_obj.update_by = str(approver.id)
+        db.flush()
+        return apply_obj
+
+    @staticmethod
+    def reject(db: Session, apply_id: int, approver, opinion: str) -> AdvanceApply | None:
+        apply_obj = (
+            db.query(AdvanceApply)
+            .filter(AdvanceApply.id == apply_id, AdvanceApply.del_flag == 0)
+            .first()
+        )
+        if not apply_obj:
+            return None
+        wf = (
+            db.query(WorkflowDef)
+            .filter(WorkflowDef.id == apply_obj.wf_id, WorkflowDef.del_flag == 0)
+            .first()
+        )
+        if not wf:
+            return None
+        nodes = (
+            db.query(WorkflowNode)
+            .filter(WorkflowNode.wf_id == wf.id)
+            .order_by(WorkflowNode.node_order)
+            .all()
+        )
+        current_node = None
+        for node in nodes:
+            if node.node_name == apply_obj.current_node:
+                current_node = node
+                break
+        if current_node is None:
+            return None
+        if current_node.approver_id != approver.id:
+            return None
+        approval = AdvanceApproval(
+            apply_id=apply_id,
+            approver_id=approver.id,
+            approver_name=approver.username,
+            approval_opinion=opinion,
+            node_name=current_node.node_name,
+            node_order=current_node.node_order,
+            approval_status="rejected",
+            approval_time=datetime.now(),
+        )
+        db.add(approval)
+        apply_obj.status = "rejected"
+        apply_obj.current_node = None
+        apply_obj.update_by = str(approver.id)
+        db.flush()
+        return apply_obj
+
+    @staticmethod
+    def issue(db: Session, apply_id: int, user, remark: str = "") -> AdvanceApply | None:
+        apply_obj = (
+            db.query(AdvanceApply)
+            .filter(AdvanceApply.id == apply_id, AdvanceApply.del_flag == 0)
+            .first()
+        )
+        if not apply_obj:
+            return None
+        apply_obj.status = "issued"
+        apply_obj.update_by = str(user.id)
+        if remark:
+            apply_obj.remark = remark
+        db.flush()
+        return apply_obj
+
+    @staticmethod
+    def get_approvals(db: Session, apply_id: int) -> list[AdvanceApproval]:
+        return (
+            db.query(AdvanceApproval)
+            .filter(AdvanceApproval.apply_id == apply_id)
+            .order_by(AdvanceApproval.id)
+            .all()
+        )

+ 40 - 0
bx/bx-backend/app/services/attachment.py

@@ -0,0 +1,40 @@
+from sqlalchemy.orm import Session
+
+from app.models.attachment import Attachment
+from app.services.base import CrudService
+
+
+class AttachmentService:
+
+    @staticmethod
+    def get_by_id(db: Session, id: int) -> Attachment | None:
+        return CrudService.get_by_id(Attachment, db, id)
+
+    @staticmethod
+    def get_paged(db: Session, page: int, page_size: int, filters: list | None = None):
+        return CrudService.get_paged(Attachment, db, page, page_size, filters)
+
+    @staticmethod
+    def create(db: Session, obj_in: dict) -> Attachment:
+        return CrudService.create(Attachment, db, obj_in)
+
+    @staticmethod
+    def update(db: Session, id: int, obj_in: dict) -> Attachment | None:
+        return CrudService.update(Attachment, db, id, obj_in)
+
+    @staticmethod
+    def soft_delete(db: Session, id: int) -> bool:
+        return CrudService.soft_delete(Attachment, db, id)
+
+    @staticmethod
+    def get_by_biz(db: Session, biz_type: str, biz_id: int) -> list[Attachment]:
+        return (
+            db.query(Attachment)
+            .filter(
+                Attachment.biz_type == biz_type,
+                Attachment.biz_id == biz_id,
+                Attachment.del_flag == 0,
+            )
+            .order_by(Attachment.id)
+            .all()
+        )

+ 66 - 0
bx/bx-backend/app/services/base.py

@@ -0,0 +1,66 @@
+from typing import TypeVar, Type, Sequence, Generic
+
+from sqlalchemy.orm import Session
+
+from app.models.base import BaseModel
+
+M = TypeVar("M", bound=BaseModel)
+
+
+class CrudService(Generic[M]):
+
+    @staticmethod
+    def get_by_id(model: Type[M], db: Session, id: int) -> M | None:
+        query = db.query(model)
+        if hasattr(model, "del_flag"):
+            query = query.filter(model.del_flag == 0)
+        return query.filter(model.id == id).first()
+
+    @staticmethod
+    def get_paged(
+        model: Type[M],
+        db: Session,
+        page: int,
+        page_size: int,
+        filters: list | None = None,
+    ) -> tuple[Sequence[M], int]:
+        query = db.query(model)
+        if hasattr(model, "del_flag"):
+            query = query.filter(model.del_flag == 0)
+        if filters:
+            query = query.filter(*filters)
+        total = query.count()
+        rows = query.offset((page - 1) * page_size).limit(page_size).all()
+        return rows, total
+
+    @staticmethod
+    def create(model: Type[M], db: Session, obj_in: dict) -> M:
+        obj = model(**obj_in)
+        db.add(obj)
+        db.flush()
+        return obj
+
+    @staticmethod
+    def update(model: Type[M], db: Session, id: int, obj_in: dict) -> M | None:
+        query = db.query(model)
+        if hasattr(model, "del_flag"):
+            query = query.filter(model.del_flag == 0)
+        obj = query.filter(model.id == id).first()
+        if not obj:
+            return None
+        for k, v in obj_in.items():
+            setattr(obj, k, v)
+        db.flush()
+        return obj
+
+    @staticmethod
+    def soft_delete(model: Type[M], db: Session, id: int) -> bool:
+        query = db.query(model)
+        if hasattr(model, "del_flag"):
+            query = query.filter(model.del_flag == 0)
+        obj = query.filter(model.id == id).first()
+        if not obj:
+            return False
+        obj.del_flag = 1
+        db.flush()
+        return True

+ 57 - 0
bx/bx-backend/app/services/expense_item.py

@@ -0,0 +1,57 @@
+from sqlalchemy.orm import Session
+
+from app.models.expense_item import ExpenseItem
+from app.services.base import CrudService
+
+
+class ExpenseItemService:
+
+    @staticmethod
+    def get_by_id(db: Session, id: int) -> ExpenseItem | None:
+        return CrudService.get_by_id(ExpenseItem, db, id)
+
+    @staticmethod
+    def get_paged(db: Session, page: int, page_size: int, filters: list | None = None):
+        return CrudService.get_paged(ExpenseItem, db, page, page_size, filters)
+
+    @staticmethod
+    def create(db: Session, obj_in: dict) -> ExpenseItem:
+        return CrudService.create(ExpenseItem, db, obj_in)
+
+    @staticmethod
+    def update(db: Session, id: int, obj_in: dict) -> ExpenseItem | None:
+        return CrudService.update(ExpenseItem, db, id, obj_in)
+
+    @staticmethod
+    def soft_delete(db: Session, id: int) -> bool:
+        return CrudService.soft_delete(ExpenseItem, db, id)
+
+    @staticmethod
+    def get_item_tree(db: Session) -> list[ExpenseItem]:
+        items = (
+            db.query(ExpenseItem)
+            .filter(ExpenseItem.del_flag == 0)
+            .order_by(ExpenseItem.sort_order)
+            .all()
+        )
+        return ExpenseItemService._build_tree(items)
+
+    @staticmethod
+    def _build_tree(items: list[ExpenseItem]) -> list[ExpenseItem]:
+        item_map = {item.id: item for item in items}
+        roots: list[ExpenseItem] = []
+        for item in items:
+            if not item.parent_id or item.parent_id == 0:
+                roots.append(item)
+            else:
+                parent = item_map.get(item.parent_id)
+                if parent:
+                    if not hasattr(parent, "children") or parent.children is None:
+                        parent.children = []
+                    parent.children.append(item)
+        for item in items:
+            if hasattr(item, "children") and item.children:
+                item.child_count = len(item.children)
+            else:
+                item.child_count = 0
+        return roots

+ 45 - 0
bx/bx-backend/app/services/menu.py

@@ -0,0 +1,45 @@
+from sqlalchemy.orm import Session, joinedload
+
+from app.models.menu import Menu
+from app.services.base import CrudService
+
+
+class MenuService:
+
+    @staticmethod
+    def get_by_id(db: Session, id: int) -> Menu | None:
+        return CrudService.get_by_id(Menu, db, id)
+
+    @staticmethod
+    def get_paged(db: Session, page: int, page_size: int, filters: list | None = None):
+        return CrudService.get_paged(Menu, db, page, page_size, filters)
+
+    @staticmethod
+    def create(db: Session, obj_in: dict) -> Menu:
+        return CrudService.create(Menu, db, obj_in)
+
+    @staticmethod
+    def update(db: Session, id: int, obj_in: dict) -> Menu | None:
+        return CrudService.update(Menu, db, id, obj_in)
+
+    @staticmethod
+    def soft_delete(db: Session, id: int) -> bool:
+        return CrudService.soft_delete(Menu, db, id)
+
+    @staticmethod
+    def get_menu_tree(db: Session) -> list[Menu]:
+        return (
+            db.query(Menu)
+            .filter(Menu.del_flag == 0, Menu.parent_id == 0)
+            .order_by(Menu.sort_order)
+            .options(joinedload(Menu.children))
+            .all()
+        )
+
+    @staticmethod
+    def get_menus_by_role(db: Session, role_id: int) -> list[Menu]:
+        from app.models.role import Role
+        role = db.query(Role).filter(Role.id == role_id, Role.del_flag == 0).first()
+        if not role:
+            return []
+        return list(role.menus)

+ 60 - 0
bx/bx-backend/app/services/minio.py

@@ -0,0 +1,60 @@
+import uuid
+from datetime import datetime, timedelta
+from io import BytesIO
+
+from minio import Minio
+
+from app.config import get_settings
+
+
+class MinioService:
+    _client: Minio | None = None
+    _bucket: str = ""
+
+    @staticmethod
+    def _get_client() -> tuple[Minio, str]:
+        if MinioService._client is None:
+            settings = get_settings()
+            MinioService._client = Minio(
+                endpoint=settings.MINIO_ENDPOINT,
+                access_key=settings.MINIO_ACCESS_KEY,
+                secret_key=settings.MINIO_SECRET_KEY,
+                secure=settings.MINIO_SECURE,
+            )
+            MinioService._bucket = settings.MINIO_BUCKET
+            if not MinioService._client.bucket_exists(MinioService._bucket):
+                MinioService._client.make_bucket(MinioService._bucket)
+        return MinioService._client, MinioService._bucket
+
+    @staticmethod
+    def upload_file(file_name: str, file_data: bytes, content_type: str) -> str:
+        client, bucket = MinioService._get_client()
+        ext = file_name.rsplit(".", 1)[-1] if "." in file_name else "bin"
+        date_str = datetime.now().strftime("%Y-%m-%d")
+        object_name = f"bx/{date_str}/{uuid.uuid4().hex}.{ext}"
+        data_stream = BytesIO(file_data)
+        client.put_object(
+            bucket,
+            object_name,
+            data_stream,
+            length=len(file_data),
+            content_type=content_type,
+            part_size=10 * 1024 * 1024,
+        )
+        return object_name
+
+    @staticmethod
+    def download_file(file_path: str) -> bytes:
+        client, bucket = MinioService._get_client()
+        response = client.get_object(bucket, file_path)
+        data = response.read()
+        response.close()
+        response.release_conn()
+        return data
+
+    @staticmethod
+    def get_presigned_url(file_path: str, expires: int = 3600) -> str:
+        client, bucket = MinioService._get_client()
+        return client.presigned_get_object(
+            bucket, file_path, expires=timedelta(seconds=expires)
+        )

+ 202 - 0
bx/bx-backend/app/services/reimburse.py

@@ -0,0 +1,202 @@
+from datetime import datetime
+
+from sqlalchemy.orm import Session
+
+from app.models.reimburse import ReimburseApply, ReimburseApproval
+from app.models.workflow import WorkflowDef, WorkflowNode
+from app.services.base import CrudService
+from app.utils.codegen import generate_apply_code
+
+
+class ReimburseService:
+
+    @staticmethod
+    def get_by_id(db: Session, id: int) -> ReimburseApply | None:
+        return CrudService.get_by_id(ReimburseApply, db, id)
+
+    @staticmethod
+    def get_paged(db: Session, page: int, page_size: int, filters: list | None = None):
+        return CrudService.get_paged(ReimburseApply, db, page, page_size, filters)
+
+    @staticmethod
+    def create(db: Session, obj_in: dict) -> ReimburseApply:
+        return CrudService.create(ReimburseApply, db, obj_in)
+
+    @staticmethod
+    def update(db: Session, id: int, obj_in: dict) -> ReimburseApply | None:
+        return CrudService.update(ReimburseApply, db, id, obj_in)
+
+    @staticmethod
+    def soft_delete(db: Session, id: int) -> bool:
+        return CrudService.soft_delete(ReimburseApply, db, id)
+
+    @staticmethod
+    def submit(db: Session, apply_id: int, user) -> ReimburseApply | None:
+        apply_obj = (
+            db.query(ReimburseApply)
+            .filter(ReimburseApply.id == apply_id, ReimburseApply.del_flag == 0)
+            .first()
+        )
+        if not apply_obj:
+            return None
+        apply_obj.status = "pending"
+        if not apply_obj.apply_code:
+            apply_obj.apply_code = generate_apply_code(db, "BX")
+        apply_obj.update_by = str(user.id)
+        wf = (
+            db.query(WorkflowDef)
+            .filter(WorkflowDef.id == apply_obj.wf_id, WorkflowDef.del_flag == 0)
+            .first()
+        )
+        if wf:
+            nodes = (
+                db.query(WorkflowNode)
+                .filter(WorkflowNode.wf_id == wf.id)
+                .order_by(WorkflowNode.node_order)
+                .all()
+            )
+            if nodes:
+                apply_obj.current_node = nodes[0].node_name
+        db.flush()
+        return apply_obj
+
+    @staticmethod
+    def approve(db: Session, apply_id: int, approver, opinion: str) -> ReimburseApply | None:
+        apply_obj = (
+            db.query(ReimburseApply)
+            .filter(ReimburseApply.id == apply_id, ReimburseApply.del_flag == 0)
+            .first()
+        )
+        if not apply_obj:
+            return None
+        wf = (
+            db.query(WorkflowDef)
+            .filter(WorkflowDef.id == apply_obj.wf_id, WorkflowDef.del_flag == 0)
+            .first()
+        )
+        if not wf:
+            return None
+        nodes = (
+            db.query(WorkflowNode)
+            .filter(WorkflowNode.wf_id == wf.id)
+            .order_by(WorkflowNode.node_order)
+            .all()
+        )
+        current_idx = None
+        for i, node in enumerate(nodes):
+            if node.node_name == apply_obj.current_node:
+                current_idx = i
+                break
+        if current_idx is None:
+            return None
+        current_node = nodes[current_idx]
+        if current_node.approver_id != approver.id:
+            return None
+        approval = ReimburseApproval(
+            apply_id=apply_id,
+            approver_id=approver.id,
+            approver_name=approver.username,
+            approval_opinion=opinion,
+            node_name=current_node.node_name,
+            node_order=current_node.node_order,
+            approval_status="approved",
+            approval_time=datetime.now(),
+        )
+        db.add(approval)
+        if current_idx == len(nodes) - 1:
+            apply_obj.status = "approved"
+            apply_obj.current_node = None
+        else:
+            apply_obj.current_node = nodes[current_idx + 1].node_name
+        apply_obj.update_by = str(approver.id)
+        db.flush()
+        return apply_obj
+
+    @staticmethod
+    def reject(db: Session, apply_id: int, approver, opinion: str) -> ReimburseApply | None:
+        apply_obj = (
+            db.query(ReimburseApply)
+            .filter(ReimburseApply.id == apply_id, ReimburseApply.del_flag == 0)
+            .first()
+        )
+        if not apply_obj:
+            return None
+        wf = (
+            db.query(WorkflowDef)
+            .filter(WorkflowDef.id == apply_obj.wf_id, WorkflowDef.del_flag == 0)
+            .first()
+        )
+        if not wf:
+            return None
+        nodes = (
+            db.query(WorkflowNode)
+            .filter(WorkflowNode.wf_id == wf.id)
+            .order_by(WorkflowNode.node_order)
+            .all()
+        )
+        current_node = None
+        for node in nodes:
+            if node.node_name == apply_obj.current_node:
+                current_node = node
+                break
+        if current_node is None:
+            return None
+        if current_node.approver_id != approver.id:
+            return None
+        approval = ReimburseApproval(
+            apply_id=apply_id,
+            approver_id=approver.id,
+            approver_name=approver.username,
+            approval_opinion=opinion,
+            node_name=current_node.node_name,
+            node_order=current_node.node_order,
+            approval_status="rejected",
+            approval_time=datetime.now(),
+        )
+        db.add(approval)
+        apply_obj.status = "rejected"
+        apply_obj.current_node = None
+        apply_obj.update_by = str(approver.id)
+        db.flush()
+        return apply_obj
+
+    @staticmethod
+    def pay(db: Session, apply_id: int, user) -> ReimburseApply | None:
+        apply_obj = (
+            db.query(ReimburseApply)
+            .filter(ReimburseApply.id == apply_id, ReimburseApply.del_flag == 0)
+            .first()
+        )
+        if not apply_obj:
+            return None
+        apply_obj.status = "paid"
+        apply_obj.update_by = str(user.id)
+        db.flush()
+        return apply_obj
+
+    @staticmethod
+    def offset(
+        db: Session, apply_id: int, advance_id: int, amount: float, user
+    ) -> ReimburseApply | None:
+        apply_obj = (
+            db.query(ReimburseApply)
+            .filter(ReimburseApply.id == apply_id, ReimburseApply.del_flag == 0)
+            .first()
+        )
+        if not apply_obj:
+            return None
+        apply_obj.status = "offset"
+        apply_obj.offset_advance_id = advance_id
+        apply_obj.offset_amount = amount
+        apply_obj.update_by = str(user.id)
+        db.flush()
+        return apply_obj
+
+    @staticmethod
+    def get_approvals(db: Session, apply_id: int) -> list[ReimburseApproval]:
+        return (
+            db.query(ReimburseApproval)
+            .filter(ReimburseApproval.apply_id == apply_id)
+            .order_by(ReimburseApproval.id)
+            .all()
+        )

+ 35 - 0
bx/bx-backend/app/services/role.py

@@ -0,0 +1,35 @@
+from sqlalchemy.orm import Session
+
+from app.models.role import Role
+from app.models.user import User
+from app.services.base import CrudService
+
+
+class RoleService:
+
+    @staticmethod
+    def get_by_id(db: Session, id: int) -> Role | None:
+        return CrudService.get_by_id(Role, db, id)
+
+    @staticmethod
+    def get_paged(db: Session, page: int, page_size: int, filters: list | None = None):
+        return CrudService.get_paged(Role, db, page, page_size, filters)
+
+    @staticmethod
+    def create(db: Session, obj_in: dict) -> Role:
+        return CrudService.create(Role, db, obj_in)
+
+    @staticmethod
+    def update(db: Session, id: int, obj_in: dict) -> Role | None:
+        return CrudService.update(Role, db, id, obj_in)
+
+    @staticmethod
+    def soft_delete(db: Session, id: int) -> bool:
+        return CrudService.soft_delete(Role, db, id)
+
+    @staticmethod
+    def get_users_by_role(db: Session, role_id: int) -> list[User]:
+        role = db.query(Role).filter(Role.id == role_id, Role.del_flag == 0).first()
+        if not role:
+            return []
+        return list(role.users)

+ 49 - 0
bx/bx-backend/app/services/user.py

@@ -0,0 +1,49 @@
+from sqlalchemy.orm import Session
+
+from app.core.security import verify_password
+from app.models.user import User
+from app.models.role import Role
+from app.services.base import CrudService
+
+
+class UserService:
+
+    @staticmethod
+    def get_by_id(db: Session, id: int) -> User | None:
+        return CrudService.get_by_id(User, db, id)
+
+    @staticmethod
+    def get_paged(db: Session, page: int, page_size: int, filters: list | None = None):
+        return CrudService.get_paged(User, db, page, page_size, filters)
+
+    @staticmethod
+    def create(db: Session, obj_in: dict) -> User:
+        return CrudService.create(User, db, obj_in)
+
+    @staticmethod
+    def update(db: Session, id: int, obj_in: dict) -> User | None:
+        return CrudService.update(User, db, id, obj_in)
+
+    @staticmethod
+    def soft_delete(db: Session, id: int) -> bool:
+        return CrudService.soft_delete(User, db, id)
+
+    @staticmethod
+    def authenticate(db: Session, username: str, password: str) -> User | None:
+        user = (
+            db.query(User)
+            .filter(User.username == username, User.del_flag == 0, User.status == 1)
+            .first()
+        )
+        if not user:
+            return None
+        if not verify_password(password, user.password):
+            return None
+        return user
+
+    @staticmethod
+    def get_user_roles(db: Session, user_id: int) -> list[Role]:
+        user = db.query(User).filter(User.id == user_id, User.del_flag == 0).first()
+        if not user:
+            return []
+        return list(user.roles)

+ 47 - 0
bx/bx-backend/app/services/workflow.py

@@ -0,0 +1,47 @@
+from sqlalchemy.orm import Session
+
+from app.models.workflow import WorkflowDef, WorkflowNode
+from app.services.base import CrudService
+
+
+class WorkflowService:
+
+    @staticmethod
+    def get_by_id(db: Session, id: int) -> WorkflowDef | None:
+        return CrudService.get_by_id(WorkflowDef, db, id)
+
+    @staticmethod
+    def get_paged(db: Session, page: int, page_size: int, filters: list | None = None):
+        return CrudService.get_paged(WorkflowDef, db, page, page_size, filters)
+
+    @staticmethod
+    def create(db: Session, obj_in: dict) -> WorkflowDef:
+        return CrudService.create(WorkflowDef, db, obj_in)
+
+    @staticmethod
+    def update(db: Session, id: int, obj_in: dict) -> WorkflowDef | None:
+        return CrudService.update(WorkflowDef, db, id, obj_in)
+
+    @staticmethod
+    def soft_delete(db: Session, id: int) -> bool:
+        return CrudService.soft_delete(WorkflowDef, db, id)
+
+    @staticmethod
+    def get_by_biz_type(db: Session, biz_type: str) -> WorkflowDef | None:
+        return (
+            db.query(WorkflowDef)
+            .filter(WorkflowDef.biz_type == biz_type, WorkflowDef.del_flag == 0)
+            .first()
+        )
+
+    @staticmethod
+    def save_nodes(db: Session, wf_id: int, nodes: list[dict]) -> list[WorkflowNode]:
+        db.query(WorkflowNode).filter(WorkflowNode.wf_id == wf_id).delete()
+        created: list[WorkflowNode] = []
+        for node_data in nodes:
+            node_data["wf_id"] = wf_id
+            node = WorkflowNode(**node_data)
+            db.add(node)
+            created.append(node)
+        db.flush()
+        return created

+ 0 - 0
bx/bx-backend/app/utils/__init__.py


BIN
bx/bx-backend/app/utils/__pycache__/__init__.cpython-311.pyc


Някои файлове не бяха показани, защото твърде много файлове са промени