Skip to content

ISSUE-065: Contract History and Evolution Tracking #78

@AliiiBenn

Description

@AliiiBenn

ISSUE-065: Contract History and Evolution Tracking

Description

The current Employee model only tracks the current contract (start date). The system needs to maintain a complete history of contracts with start dates, end dates, contract types, and evolution (promotions, status changes) to properly track employee career progression.

Current State

Employee model has:

class Employee(BaseModel):
    # ...
    entry_date = DateField()  # Hire date
    contract_type = CharField(null=True)  # Single value
    # No contract history

Missing:

  • Contract history
  • Start and end dates for each contract
  • Contract type evolution (CDI, CDD, interim, etc.)
  • Salary evolution
  • Position changes
  • Status changes
  • Trial periods

Expected Behavior

Contract History Model

Add to: src/employee/models.py

class Contract(BaseModel):
    """Represent an employment contract."""

    id = AutoField()
    employee = ForeignKeyField(Employee, backref="contracts")

    # Contract details
    contract_type = CharField(max_length=50)  # CDI, CDD, Interim, Apprentissage, Stage
    start_date = DateField()
    end_date = DateField(null=True)  # Null for CDI (permanent)
    trial_period_end = DateField(null=True)
    weekly_hours = DecimalField(max_digits=4, decimal_places=2, default=35.0)
    gross_salary = DecimalField(max_digits=10, decimal_places=2, null=True)

    # Position and department
    position = CharField(max_length=100)
    department = CharField(max_length=100)
    manager = CharField(max_length=100, null=True)

    # Status
    status = CharField(max_length=20, default="active")  # active, ended, cancelled
    end_reason = CharField(max_length=100, null=True)  # resignation, termination, completion, etc.

    # Documents
    contract_document_path = CharField(max_length=500, null=True)
    amendments = TextField(null=True)  # JSON array of amendments

    # Metadata
    created_at = DateTimeField(default=datetime.now)
    updated_at = DateTimeField(default=datetime.now)
    created_by = CharField(max_length=100, null=True)
    notes = TextField(null=True)

    def __str__(self):
        return f"{self.employee.full_name} - {self.contract_type} ({self.start_date})"

    @property
    def is_current(self) -> bool:
        """Check if this is the current active contract."""
        if self.status != "active":
            return False

        today = date.today()

        # Hasn't started yet
        if self.start_date > today:
            return False

        # Has end date and has passed
        if self.end_date and self.end_date < today:
            return False

        return True

    @property
    def duration_days(self) -> Optional[int]:
        """Calculate contract duration in days."""
        if not self.end_date:
            return None  # Ongoing (CDI)
        return (self.end_date - self.start_date).days

    @property
    def is_trial_period(self) -> bool:
        """Check if currently in trial period."""
        if not self.trial_period_end:
            return False
        return date.today() <= self.trial_period_end

    class Meta:
        indexes = (
            (("employee", "start_date"), False),  # Chronological order
            ("employee", False),
            ("end_date", False),  # Expiring contracts
        )


class ContractAmendment(BaseModel):
    """Represent changes to a contract."""

    id = AutoField()
    contract = ForeignKeyField(Contract, backref="amendments")

    # Amendment details
    amendment_date = DateField()
    amendment_type = CharField(max_length=50)  # salary_change, position_change, hours_change, other
    description = TextField()

    # Old values (before change)
    old_field_name = CharField(max_length=50)
    old_value = CharField(max_length=500, null=True)

    # New values (after change)
    new_value = CharField(max_length=500, null=True)

    # Metadata
    created_at = DateTimeField(default=datetime.now)
    created_by = CharField(max_length=100, null=True)
    document_path = CharField(max_length=500, null=True)  # Amendment document

    def __str__(self):
        return f"{self.contract} - {self.amendment_type} ({self.amendment_date})"

Update Employee Model

class Employee(BaseModel):
    """Employee model (updated)."""

    # ... existing fields ...

    # Remove or deprecate these:
    # contract_type = CharField(null=True)  # Use Contract.history instead

    @property
    def current_contract(self) -> Optional[Contract]:
        """Get current active contract."""
        try:
            return Contract.select().where(
                (Contract.employee == self) &
                (Contract.status == "active")
            ).order_by(Contract.start_date.desc()).first()
        except Contract.DoesNotExist:
            return None

    @property
    def contract_history(self) -> list[Contract]:
        """Get all contracts in chronological order."""
        return list(
            Contract.select()
            .where(Contract.employee == self)
            .order_by(Contract.start_date.desc())
        )

    @property
    def tenure_days(self) -> int:
        """Calculate total tenure (days since first hire)."""
        first_contract = Contract.select().where(
            Contract.employee == self
        ).order_by(Contract.start_date).first()

        if not first_contract:
            return 0

        return (date.today() - first_contract.start_date).days

    @property
    def experience_years(self) -> float:
        """Calculate total experience in years."""
        return self.tenure_days / 365.25

    @property
    def position_history(self) -> list[dict]:
        """Get position changes over time."""
        contracts = self.contract_history
        positions = []

        for contract in contracts:
            positions.append({
                "position": contract.position,
                "department": contract.department,
                "start_date": contract.start_date,
                "end_date": contract.end_date,
                "contract_type": contract.contract_type
            })

        return positions

Contract Management UI

Create: src/ui_ctk/views/contract_history_view.py

class ContractHistoryView(ctk.CTkFrame):
    """View for managing employee contract history."""

    def __init__(self, parent, employee, **kwargs):
        super().__init__(parent, **kwargs)
        self.employee = employee
        self.create_widgets()
        self.load_contracts()

    def create_widgets(self):
        """Create widgets."""
        # Employee info header
        header = ctk.CTkFrame(self)
        header.pack(fill="x", padx=10, pady=10)

        ctk.CTkLabel(
            header,
            text=f"Contract History: {self.employee.full_name}",
            font=("Arial", 16, "bold")
        ).pack(side="left", padx=10)

        ctk.CTkLabel(
            header,
            text=f"Tenure: {self.employee.experience_years:.1f} years"
        ).pack(side="left", padx=10)

        # Contract list
        list_frame = ctk.CTkFrame(self)
        list_frame.pack(fill="both", expand=True, padx=10, pady=10)

        # Table header
        header_row = ctk.CTkFrame(list_frame)
        header_row.pack(fill="x")

        headers = ["Type", "Period", "Position", "Department", "Status"]
        for i, header_text in enumerate(headers):
            ctk.CTkLabel(
                header_row,
                text=header_text,
                font=("Arial", 12, "bold"),
                width=150
            ).pack(side="left", padx=5, pady=5)

        # Contract rows
        self.contracts_frame = ctk.CTkScrollableFrame(list_frame)
        self.contracts_frame.pack(fill="both", expand=True)

        # Buttons
        button_frame = ctk.CTkFrame(self)
        button_frame.pack(fill="x", padx=10, pady=10)

        ctk.CTkButton(
            button_frame,
            text="Add New Contract",
            command=self.add_contract,
            fg_color="green"
        ).pack(side="left", padx=5)

        ctk.CTkButton(
            button_frame,
            text="Edit Contract",
            command=self.edit_contract
        ).pack(side="left", padx=5)

        ctk.CTkButton(
            button_frame,
            text="View Amendments",
            command=self.view_amendments
        ).pack(side="left", padx=5)

    def load_contracts(self):
        """Load and display contracts."""
        # Clear existing
        for widget in self.contracts_frame.winfo_children():
            widget.destroy()

        # Load contracts
        for contract in self.employee.contract_history:
            row = self.create_contract_row(contract)
            row.pack(fill="x", padx=5, pady=2)

    def create_contract_row(self, contract: Contract):
        """Create a row displaying contract info."""
        row = ctk.CTkFrame(self.contracts_frame)

        # Contract type
        type_label = ctk.CTkLabel(row, text=contract.contract_type, width=150)
        type_label.pack(side="left", padx=5)

        # Period
        if contract.end_date:
            period = f"{contract.start_date}{contract.end_date}"
        else:
            period = f"{contract.start_date} → Present"
        period_label = ctk.CTkLabel(row, text=period, width=150)
        period_label.pack(side="left", padx=5)

        # Position
        pos_label = ctk.CTkLabel(row, text=contract.position, width=150)
        pos_label.pack(side="left", padx=5)

        # Department
        dept_label = ctk.CTkLabel(row, text=contract.department, width=150)
        dept_label.pack(side="left", padx=5)

        # Status
        status_color = "green" if contract.is_current else "gray"
        status_text = "Current" if contract.is_current else contract.status.title()
        status_label = ctk.CTkLabel(
            row,
            text=status_text,
            text_color=status_color,
            width=150
        )
        status_label.pack(side="left", padx=5)

        return row


class ContractForm(ctk.CTkToplevel):
    """Dialog for adding/editing contracts."""

    def __init__(self, parent, employee, contract=None):
        super().__init__(parent)
        self.employee = employee
        self.contract = contract

        self.title("Contract Details")
        self.geometry("600x700")
        self.transient(parent)
        self.grab_set()

        self.create_widgets()
        if contract:
            self.load_contract_data(contract)

    def create_widgets(self):
        """Create form widgets."""
        main = ctk.CTkFrame(self)
        main.pack(fill="both", expand=True, padx=20, pady=20)

        # Contract type
        self.contract_type = self.create_labeled_input(
            main,
            "Contract Type:",
            ctk.CTkComboBox,
            values=["CDI", "CDD", "Interim", "Apprentissage", "Stage", "Freelance"]
        )

        # Dates
        self.start_date = self.create_labeled_input(
            main,
            "Start Date:",
            ctk.CTkEntry
        )

        self.end_date = self.create_labeled_input(
            main,
            "End Date (optional for CDI):",
            ctk.CTkEntry
        )

        self.trial_period_end = self.create_labeled_input(
            main,
            "Trial Period End (optional):",
            ctk.CTkEntry
        )

        # Position and department
        self.position = self.create_labeled_input(
            main,
            "Position:",
            ctk.CTkComboBox,
            values=["Operateur", "Magasinier", "Cariste", "Chef d'équipe"]
        )

        self.department = self.create_labeled_input(
            main,
            "Department:",
            ctk.CTkComboBox,
            values=["Logistique", "Production", "Maintenance", "Administration"]
        )

        # Salary and hours
        self.gross_salary = self.create_labeled_input(
            main,
            "Gross Salary (€):",
            ctk.CTkEntry
        )

        self.weekly_hours = self.create_labeled_input(
            main,
            "Weekly Hours:",
            ctk.CTkEntry,
            default="35.0"
        )

        # Status
        self.status = self.create_labeled_input(
            main,
            "Status:",
            ctk.CTkComboBox,
            values=["active", "ended", "cancelled"]
        )

        # Document upload
        doc_frame = ctk.CTkFrame(main)
        doc_frame.pack(fill="x", pady=10)

        ctk.CTkLabel(doc_frame, text="Contract Document:").pack(side="left", padx=5)

        self.document_path = ctk.CTkLabel(doc_frame, text="No file selected")
        self.document_path.pack(side="left", padx=5)

        ctk.CTkButton(
            doc_frame,
            text="Browse",
            command=self.browse_document,
            width=100
        ).pack(side="left", padx=5)

        # Buttons
        button_frame = ctk.CTkFrame(main)
        button_frame.pack(fill="x", pady=20)

        ctk.CTkButton(
            button_frame,
            text="Save",
            command=self.save_contract,
            fg_color="green"
        ).pack(side="left", padx=5)

        ctk.CTkButton(
            button_frame,
            text="Cancel",
            command=self.destroy
        ).pack(side="left", padx=5)

    def save_contract(self):
        """Save contract to database."""
        from employee.models import Contract
        from utils.file_storage import DocumentStorageManager

        # Validate inputs
        # ... validation code ...

        # Prepare data
        data = {
            "employee": self.employee,
            "contract_type": self.contract_type.get(),
            "start_date": self.parse_date(self.start_date.get()),
            "end_date": self.parse_date(self.end_date.get()) if self.end_date.get() else None,
            "trial_period_end": self.parse_date(self.trial_period_end.get()) if self.trial_period_end.get() else None,
            "position": self.position.get(),
            "department": self.department.get(),
            "gross_salary": Decimal(self.gross_salary.get()) if self.gross_salary.get() else None,
            "weekly_hours": Decimal(self.weekly_hours.get()),
            "status": self.status.get(),
        }

        if self.contract:
            # Update existing
            for key, value in data.items():
                setattr(self.contract, key, value)
            self.contract.updated_at = datetime.now()
            self.contract.save()
        else:
            # Create new
            self.contract = Contract.create(**data)

        # Handle document upload
        if hasattr(self, 'uploaded_document_path'):
            storage = DocumentStorageManager()
            stored_path = storage.store_document(
                doc_type="contracts",
                matricule=self.employee.matricule,
                file_path=Path(self.uploaded_document_path),
                metadata={
                    "file_name": Path(self.uploaded_document_path).name,
                    "document_type": "contract",
                    "contract_id": self.contract.id,
                    "version": 1
                }
            )
            self.contract.contract_document_path = str(stored_path)
            self.contract.save()

        self.destroy()

Contract Evolution Tracking

# src/reports/contract_evolution_report.py
def generate_evolution_report(employee: Employee) -> dict:
    """Generate contract evolution report for an employee."""
    contracts = employee.contract_history

    report = {
        "employee": employee,
        "total_contracts": len(contracts),
        "total_tenure_days": employee.tenure_days,
        "position_changes": [],
        "salary_evolution": [],
        "department_changes": [],
        "contract_type_changes": [],
        "gaps": []  # Periods without contract
    }

    previous_contract = None

    for contract in contracts:
        # Track position changes
        if previous_contract and previous_contract.position != contract.position:
            report["position_changes"].append({
                "from": previous_contract.position,
                "to": contract.position,
                "date": contract.start_date
            })

        # Track salary changes
        if contract.gross_salary:
            report["salary_evolution"].append({
                "salary": float(contract.gross_salary),
                "date": contract.start_date,
                "position": contract.position
            })

        # Track department changes
        if previous_contract and previous_contract.department != contract.department:
            report["department_changes"].append({
                "from": previous_contract.department,
                "to": contract.department,
                "date": contract.start_date
            })

        # Check for gaps between contracts
        if previous_contract:
            if previous_contract.end_date and contract.start_date:
                gap = (contract.start_date - previous_contract.end_date).days
                if gap > 0:
                    report["gaps"].append({
                        "start": previous_contract.end_date,
                        "end": contract.start_date,
                        "days": gap
                    })

        previous_contract = contract

    return report

Affected Files

  • src/employee/models.py - Add Contract and ContractAmendment models
  • src/ui_ctk/views/contract_history_view.py - New contract history view
  • src/ui_ctk/forms/contract_form.py - New contract form
  • src/reports/contract_evolution_report.py - New evolution report
  • Database migration for new tables
  • Employee detail view - Add contract history tab

Implementation Plan

Phase 1: Data Model (1 day)

  1. Create Contract model
  2. Create ContractAmendment model
  3. Update Employee model with properties
  4. Create database migration
  5. Add indexes for performance

Phase 2: Contract Management UI (2 days)

  1. Create contract history view
  2. Create contract form (add/edit)
  3. Integrate with employee detail view
  4. Add document upload support

Phase 3: Evolution Tracking (1 day)

  1. Implement evolution report generator
  2. Track position/salary/department changes
  3. Detect gaps in employment
  4. Create timeline visualization

Phase 4: Migration from Old System (1 day)

  1. Migrate existing entry_date to first contract
  2. Migrate contract_type if exists
  3. Validate data integrity
  4. Test migration

Phase 5: Integration and Testing (1 day)

  1. Update alerts view to check contract expiration
  2. Add contract alerts to configurable alerts
  3. Test contract workflows
  4. Performance testing

Dependencies

  • ISSUE-060: Hierarchical Document Storage (contract documents)
  • ISSUE-061: Configurable Alert System (contract expiration alerts)

Related Issues

  • ISSUE-060: Hierarchical Document Storage
  • ISSUE-061: Configurable Alert System
  • ISSUE-064: Contract History Tracking

Acceptance Criteria

  • Contract model implemented
  • ContractAmendment model implemented
  • Employee.current_contract property works
  • Employee.contract_history property works
  • Contract history view functional
  • Can add/edit/delete contracts
  • Document upload works for contracts
  • Evolution report generated
  • Position changes tracked
  • Salary evolution tracked
  • Gaps in employment detected
  • Contract expiration alerts work
  • Migration from old system successful
  • All tests pass

Estimated Effort

Total: 5-6 days

  • Data model: 1 day
  • Contract management UI: 2 days
  • Evolution tracking: 1 day
  • Migration: 1 day
  • Integration and testing: 1 day

Notes

This is a critical HR feature. Proper contract tracking ensures legal compliance and provides a complete employment history. The evolution tracking helps identify career progression and patterns in employee movements.

Example Use Cases

Use Case 1: Contract Renewal

  1. Initial Contract: CDD from 2024-01-01 to 2024-12-31
  2. Alert: System warns at D-90 (October 2024)
  3. Renewal: Create new CDI contract from 2025-01-01
  4. History: Both contracts visible in timeline
  5. Evolution: Shows transition from CDD to CDI

Use Case 2: Promotion

  1. Initial Contract: CDI as Magasinier (2023-01-01)
  2. Promotion: Amendment to Chef d'Équipe (2024-06-01)
  3. Salary Update: Amendment with new salary (2024-06-01)
  4. History: Shows evolution with salary progression
  5. Report: Career path visualization

Use Case 3: Re-hiring

  1. First Contract: CDD (2022-01-01 to 2022-12-31)
  2. Gap: No contract (2023-01-01 to 2023-06-30) - 6 months
  3. Re-hiring: New CDD (2023-07-01 to 2023-12-31)
  4. Gap Detection: Report shows 6-month gap
  5. Tenure: Total tenure calculated from first hire

Future Enhancements

  • Contract templates (pre-filled standard contracts)
  • Automatic contract generation from templates
  • Contract renewal workflow (approval process)
  • Integration with payroll software
  • Contract expiration notifications
  • Trial period alerts
  • Multi-contract tracking (multiple simultaneous contracts)
  • Export contract history to PDF

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions