38686-vm/core/models.py
Konrad du Plessis c1d9014fe1 ux(labels): shorter adjustment type labels (display-only rename)
Path A rename - DB values untouched, only TYPE_CHOICES display
labels change:
  'New Loan'          -> shown as 'Loan'
  'Advance Payment'   -> shown as 'Advance'
  'Advance Repayment' -> shown as 'Advance Repaid'

Templates that render the type as visible text switched from
{{ adj.type }} to {{ adj.get_type_display }}. Data attributes and
CSS class slugs keep the raw DB value (identifiers, not labels).

Zero data migration. Zero changes to ADDITIVE_TYPES / DEDUCTIVE_TYPES
constants, hardcoded string comparisons, CSS class names, test
fixtures, or any other code that references the canonical DB value.
Every historic PayrollAdjustment row keeps type='New Loan' /
'Advance Payment' / 'Advance Repayment' as stored.

Django's makemigrations generated a no-op AlterField migration to
record the choices-metadata change.

Tests: 69/69.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:49:26 +02:00

365 lines
15 KiB
Python

import datetime
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
from decimal import Decimal
from django.db.models.signals import post_save
from django.dispatch import receiver
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
# Add any extra profile fields if needed in the future
def __str__(self):
return self.user.username
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
UserProfile.objects.get_or_create(user=instance)
@receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs):
if hasattr(instance, 'profile'):
instance.profile.save()
class Project(models.Model):
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
supervisors = models.ManyToManyField(User, related_name='assigned_projects')
active = models.BooleanField(default=True)
start_date = models.DateField(blank=True, null=True)
end_date = models.DateField(blank=True, null=True)
def __str__(self):
return self.name
class Worker(models.Model):
name = models.CharField(max_length=200)
id_number = models.CharField(max_length=50, unique=True)
phone_number = models.CharField(max_length=20, blank=True)
monthly_salary = models.DecimalField(max_digits=10, decimal_places=2)
# === BANKING & TAX ===
# Payroll-related identifiers. Shown in the "Personal Info" fieldset in
# Django admin and the "Personal & Pay" section of the friendly edit form.
# verbose_name becomes the form label; help_text becomes the tooltip
# (friendly page) or the admin's under-field hint.
tax_number = models.CharField(
'Tax No',
max_length=50, blank=True,
help_text='Registered Tax Number',
)
uif_number = models.CharField(
'UIF',
max_length=50, blank=True,
help_text='Unemployment Insurance Fund number',
)
bank_name = models.CharField(
'Bank',
max_length=100, blank=True,
help_text='Account at which Institution',
)
bank_account_number = models.CharField(
'Acc No.',
max_length=50, blank=True,
help_text='Bank account number',
)
photo = models.ImageField(upload_to='workers/photos/', blank=True, null=True)
id_document = models.FileField(upload_to='workers/documents/', blank=True, null=True)
employment_date = models.DateField(default=timezone.now)
notes = models.TextField(blank=True)
active = models.BooleanField(default=True)
# === SIZING ===
# Clothing and boot sizes for PPE (personal protective equipment) ordering
shoe_size = models.CharField(max_length=20, blank=True)
overall_top_size = models.CharField(max_length=10, blank=True)
pants_size = models.CharField(max_length=20, blank=True)
tshirt_size = models.CharField(max_length=10, blank=True)
# === DRIVERS LICENSE ===
# Track which workers have a valid drivers license and store a scanned copy
has_drivers_license = models.BooleanField(default=False)
drivers_license = models.FileField(upload_to='workers/documents/', blank=True, null=True)
drivers_license_code = models.CharField(
'Code',
max_length=20, blank=True,
help_text='Drivers License Code (e.g. A, B, C, EB, EC)',
)
@property
def daily_rate(self):
# monthly salary divided by 20 working days
return (self.monthly_salary / Decimal('20.00')).quantize(Decimal('0.01'))
def __str__(self):
return self.name
class Team(models.Model):
# === PAY FREQUENCY CHOICES ===
# Used for the team's recurring pay schedule (weekly, fortnightly, or monthly)
PAY_FREQUENCY_CHOICES = [
('weekly', 'Weekly'),
('fortnightly', 'Fortnightly'),
('monthly', 'Monthly'),
]
name = models.CharField(max_length=200)
workers = models.ManyToManyField(Worker, related_name='teams')
supervisor = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='supervised_teams')
active = models.BooleanField(default=True)
# === PAY SCHEDULE ===
# These two fields define when the team gets paid.
# pay_start_date is the anchor — the first day of the very first pay period.
# pay_frequency determines the length of each recurring period.
# Both are optional — teams without a schedule work as before.
pay_frequency = models.CharField(max_length=15, choices=PAY_FREQUENCY_CHOICES, blank=True, default='')
pay_start_date = models.DateField(blank=True, null=True, help_text='Anchor date for first pay period')
def __str__(self):
return self.name
class WorkLog(models.Model):
OVERTIME_CHOICES = [
(Decimal('0.00'), 'None'),
(Decimal('0.25'), '1/4 Day'),
(Decimal('0.50'), '1/2 Day'),
(Decimal('0.75'), '3/4 Day'),
(Decimal('1.00'), 'Full Day'),
]
date = models.DateField(default=timezone.now)
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='work_logs')
team = models.ForeignKey(Team, on_delete=models.SET_NULL, null=True, blank=True, related_name='work_logs')
workers = models.ManyToManyField(Worker, related_name='work_logs')
supervisor = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='work_logs_created')
notes = models.TextField(blank=True)
overtime_amount = models.DecimalField(max_digits=3, decimal_places=2, choices=OVERTIME_CHOICES, default=Decimal('0.00'))
priced_workers = models.ManyToManyField(Worker, related_name='priced_overtime_logs', blank=True)
@property
def display_amount(self):
"""Total daily cost for all workers on this log (sum of daily_rate).
Works efficiently with prefetch_related('workers')."""
return sum(w.daily_rate for w in self.workers.all())
def __str__(self):
return f"{self.date} - {self.project.name}"
class PayrollRecord(models.Model):
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='payroll_records')
date = models.DateField(default=timezone.now)
amount_paid = models.DecimalField(max_digits=10, decimal_places=2)
work_logs = models.ManyToManyField(WorkLog, related_name='payroll_records')
def __str__(self):
return f"{self.worker.name} - {self.date}"
class Loan(models.Model):
# === LOAN TYPE ===
# 'loan' = traditional loan (created via "New Loan")
# 'advance' = salary advance (created via "Advance Payment")
# Both work the same way (tracked balance, repayments) but are
# labelled differently on payslips and in the Loans tab.
LOAN_TYPE_CHOICES = [
('loan', 'Loan'),
('advance', 'Advance'),
]
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='loans')
loan_type = models.CharField(max_length=10, choices=LOAN_TYPE_CHOICES, default='loan')
principal_amount = models.DecimalField(max_digits=10, decimal_places=2)
remaining_balance = models.DecimalField(max_digits=10, decimal_places=2)
date = models.DateField(default=timezone.now)
reason = models.TextField(blank=True)
active = models.BooleanField(default=True)
def save(self, *args, **kwargs):
if not self.pk:
self.remaining_balance = self.principal_amount
super().save(*args, **kwargs)
def __str__(self):
label = 'Advance' if self.loan_type == 'advance' else 'Loan'
return f"{self.worker.name} - {label} - {self.date}"
class PayrollAdjustment(models.Model):
# === PayrollAdjustment TYPE_CHOICES - canonical DB value | display label ===
# Path A rename (24 Apr 2026): DB values are PRESERVED as-is. Only the
# second tuple element (the human label) changes for three types, so
# users see shorter labels in tables while every historic row, formula,
# constant, test fixture, CSS class, and data-attribute KEEP WORKING
# UNCHANGED because they all key off the DB value on the left.
# See CLAUDE.md "UI-vs-DB naming drift" section for the full rule.
TYPE_CHOICES = [
('Bonus', 'Bonus'),
('Overtime', 'Overtime'),
('Deduction', 'Deduction'),
('Loan Repayment', 'Loan Repayment'),
('New Loan', 'Loan'),
('Advance Payment', 'Advance'),
('Advance Repayment', 'Advance Repaid'),
]
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='adjustments')
payroll_record = models.ForeignKey(PayrollRecord, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments')
loan = models.ForeignKey(Loan, on_delete=models.SET_NULL, null=True, blank=True, related_name='repayments')
work_log = models.ForeignKey(WorkLog, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments_by_work_log')
project = models.ForeignKey(Project, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments_by_project')
amount = models.DecimalField(max_digits=10, decimal_places=2)
date = models.DateField(default=timezone.now)
description = models.TextField(blank=True)
type = models.CharField(max_length=50, choices=TYPE_CHOICES)
def __str__(self):
return f"{self.worker.name} - {self.type} - {self.amount}"
class ExpenseReceipt(models.Model):
METHOD_CHOICES = [
('Cash', 'Cash'),
('Card', 'Card'),
('EFT', 'EFT'),
('Other', 'Other'),
]
VAT_CHOICES = [
('Included', 'Included'),
('Excluded', 'Excluded'),
('None', 'None'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='expense_receipts')
date = models.DateField(default=timezone.now)
vendor_name = models.CharField(max_length=200)
description = models.TextField(blank=True)
payment_method = models.CharField(max_length=20, choices=METHOD_CHOICES)
vat_type = models.CharField(max_length=20, choices=VAT_CHOICES, default='None')
subtotal = models.DecimalField(max_digits=12, decimal_places=2)
vat_amount = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
total_amount = models.DecimalField(max_digits=12, decimal_places=2)
def __str__(self):
return f"{self.vendor_name} - {self.date}"
class ExpenseLineItem(models.Model):
receipt = models.ForeignKey(ExpenseReceipt, on_delete=models.CASCADE, related_name='line_items')
product_name = models.CharField(max_length=200)
amount = models.DecimalField(max_digits=12, decimal_places=2)
def __str__(self):
return self.product_name
# =============================================================
# === WORKER CERTIFICATIONS ===
# =============================================================
# Each row means "this worker currently holds this certificate".
# Delete the row to record that they no longer hold it.
# Use valid_until to track when the cert expires — certs without a
# valid_until date are treated as non-expiring (e.g. a completed
# skills course with no expiry).
class WorkerCertificate(models.Model):
# === CERT TYPES ===
# Fixed list for now; add more entries here when new cert types
# become relevant (e.g. scaffolding, electrical, confined spaces).
CERT_TYPES = [
('skills', 'Skills Certificate'),
('pdp', 'PDP (Professional Driving Permit)'),
('first_aid', 'First Aid'),
('medical', 'Medical'),
('work_at_height', 'Work at Height'),
]
worker = models.ForeignKey(
Worker, related_name='certificates', on_delete=models.CASCADE,
)
cert_type = models.CharField(max_length=30, choices=CERT_TYPES)
document = models.FileField(
upload_to='workers/certificates/', blank=True, null=True,
help_text='Scan or photo of the certificate',
)
issued_date = models.DateField(blank=True, null=True)
valid_until = models.DateField(
blank=True, null=True,
help_text='Expiry date — leave blank if the cert does not expire',
)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
# One row per (worker, cert_type) — no duplicate cert types per worker
constraints = [
models.UniqueConstraint(
fields=['worker', 'cert_type'],
name='unique_cert_per_worker',
),
]
ordering = ['worker', 'cert_type']
def __str__(self):
return f'{self.worker.name}{self.get_cert_type_display()}'
@property
def is_expired(self):
"""True if the certificate's valid_until date is in the past."""
if not self.valid_until:
return False
return self.valid_until < timezone.now().date()
@property
def expires_soon(self):
"""True if the cert expires within the next 30 days (but not yet expired)."""
if not self.valid_until:
return False
today = timezone.now().date()
return today <= self.valid_until <= today + datetime.timedelta(days=30)
# =============================================================
# === WORKER WARNINGS / DISCIPLINARY ===
# =============================================================
# A disciplinary record per worker. Severity escalates: Verbal →
# Written → Final. Keep all historical warnings for audit purposes;
# don't delete rows. If a warning was issued in error, update the
# reason/description to note that rather than removing it.
class WorkerWarning(models.Model):
# === SEVERITY LEVELS ===
# Standard South African labour-relations escalation order.
SEVERITY_CHOICES = [
('verbal', 'Verbal Warning'),
('written', 'Written Warning'),
('final', 'Final Warning'),
]
worker = models.ForeignKey(
Worker, related_name='warnings', on_delete=models.CASCADE,
)
date = models.DateField(default=timezone.now)
severity = models.CharField(max_length=20, choices=SEVERITY_CHOICES)
reason = models.CharField(
max_length=200,
help_text='Short summary — e.g. "Repeated lateness"',
)
description = models.TextField(
blank=True,
help_text='Full context of what happened',
)
issued_by = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True,
related_name='warnings_issued',
)
document = models.FileField(
upload_to='workers/warnings/', blank=True, null=True,
help_text='Signed warning form (optional)',
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
# Newest warnings first — that's what the UI will show at the top
ordering = ['-date']
def __str__(self):
return f'{self.worker.name}{self.get_severity_display()} ({self.date})'