Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 37 additions & 8 deletions apps/job/models/costing.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import logging
import uuid
from collections.abc import Iterable
from decimal import Decimal

from django.core.exceptions import ValidationError
from django.db import connection, models, transaction
from django.db.models import Q
from django.db.models.base import ModelBase
from django.utils import timezone

from .costline_validators import (
Expand Down Expand Up @@ -386,8 +388,11 @@ def _assign_entry_seq(self) -> None:

@staticmethod
def _with_sequence_update_fields(
update_fields, *, requires_sequence: bool, staff_newly_set: bool
):
update_fields: Iterable[str] | None,
*,
requires_sequence: bool,
staff_newly_set: bool,
) -> set[str] | None:
if update_fields is None:
return None
fields = set(update_fields)
Expand Down Expand Up @@ -430,23 +435,42 @@ def _update_cost_set_summary(self) -> None:

request_job_summary_pdf_refresh()

def save(self, *args, **kwargs):
def save(
self,
*,
force_insert: bool | tuple[ModelBase, ...] = False,
force_update: bool = False,
using: str | None = None,
update_fields: Iterable[str] | None = None,
) -> None:
staff_was_already_set = self.staff_id is not None
requires_sequence = self._actual_time_entry_requires_sequence()
with transaction.atomic():
self._assign_entry_seq()
staff_newly_set_from_legacy_meta = (
self.staff_id is not None and not staff_was_already_set
)
kwargs["update_fields"] = self._with_sequence_update_fields(
kwargs.get("update_fields"),
update_fields = self._with_sequence_update_fields(
update_fields,
requires_sequence=requires_sequence,
staff_newly_set=staff_newly_set_from_legacy_meta,
)

self._save_with_summary_update(*args, **kwargs)
self._save_with_summary_update(
force_insert=force_insert,
force_update=force_update,
using=using,
update_fields=update_fields,
)

def _save_with_summary_update(self, *args, **kwargs):
def _save_with_summary_update(
self,
*,
force_insert: bool | tuple[ModelBase, ...] = False,
force_update: bool = False,
using: str | None = None,
update_fields: Iterable[str] | None = None,
) -> None:
# Fail fast if trying to set revenue on shop jobs
job = self.cost_set.job
if job.shop_job:
Expand All @@ -464,7 +488,12 @@ def _save_with_summary_update(self, *args, **kwargs):
)

self.full_clean()
super().save(*args, **kwargs)
super().save(
force_insert=force_insert,
force_update=force_update,
using=using,
update_fields=update_fields,
)
self._update_cost_set_summary()

def delete(self, *args, **kwargs):
Expand Down
116 changes: 73 additions & 43 deletions apps/timesheet/management/commands/create_leave_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from decimal import Decimal

from django.core.management.base import BaseCommand, CommandError
from django.db import transaction

from apps.accounts.models import Staff
from apps.job.models import CostLine, CostSet, Job
Expand Down Expand Up @@ -105,6 +106,49 @@
}


def build_leave_cost_line(
staff: Staff,
cost_set: CostSet,
job: Job,
leave_type: str,
entry_date: date,
hours: Decimal,
) -> CostLine:
"""Build — but not save — one leave CostLine.

Full model validation runs inside CostLine.save() (which also assigns
entry_seq, so it cannot run earlier); the guard here exists to give the
operator a named-staff error instead of a ValidationError dump.
"""
if staff.default_labour_subtype is None:
raise CommandError(
f"Staff '{staff.get_display_full_name()}' has no "
"default_labour_subtype set"
)
label = LEAVE_JOB_NAMES[leave_type]
wage = Decimal("0") if leave_type == "unpaid" else staff.base_wage_rate
cost_line = CostLine(
cost_set=cost_set,
kind="time",
desc=f"{label} - {staff.get_display_name()}",
quantity=hours,
unit_cost=wage,
unit_rev=Decimal("0"),
accounting_date=entry_date,
staff=staff,
xero_pay_item=job.default_xero_pay_item,
labour_subtype=staff.default_labour_subtype,
meta={
"staff_id": str(staff.id),
"date": entry_date.isoformat(),
"is_billable": False,
"created_from_timesheet": True,
"wage_rate_multiplier": 1,
},
)
return cost_line


class Command(BaseCommand):
help = "Create missing leave entries to backfill JM from Xero payroll data"

Expand Down Expand Up @@ -147,7 +191,7 @@ def handle(self, *args, **options):
return

# --- Validate all entries upfront ---
validated = []
validated: list[CostLine] = []
for staff_name, entry_date, leave_type, hours in ENTRIES:
if leave_type not in leave_cost_sets:
raise CommandError(
Expand Down Expand Up @@ -210,55 +254,41 @@ def handle(self, *args, **options):
)

validated.append(
(staff, entry_date, leave_type, hours, cost_set, leave_jobs[leave_type])
build_leave_cost_line(
staff,
cost_set,
leave_jobs[leave_type],
leave_type,
entry_date,
hours,
)
)

self.stdout.write(f"Validated {len(validated)} entries.")

if dry_run:
self.stdout.write(self.style.WARNING("DRY RUN - no changes made:"))
for staff, entry_date, leave_type, hours, _, _ in validated:
label = LEAVE_JOB_NAMES[leave_type]
wage = Decimal("0") if leave_type == "unpaid" else staff.base_wage_rate
cost = wage * hours
# --- Create entries; a dry run saves them for real (exercising every
# model rule, entry_seq assignment, and DB constraint) then rolls the
# whole transaction back, so it can never report success for entries
# that would fail a live run. ---
with transaction.atomic():
for cost_line in validated:
cost_line.save()
self.stdout.write(
f" {entry_date} ({entry_date.strftime('%a')}) | "
f"{staff.get_display_name()} | {label} | "
f"{hours}h | ${cost}"
self.style.SUCCESS(
f"Created: {cost_line.accounting_date} "
f"({cost_line.accounting_date.strftime('%a')}) | "
f"{cost_line.desc} | {cost_line.quantity}h | "
f"${cost_line.total_cost} | ID: {cost_line.id}"
)
)
return

# --- Create entries (all validation passed) ---
for staff, entry_date, leave_type, hours, cost_set, job in validated:
label = LEAVE_JOB_NAMES[leave_type]

wage = Decimal("0") if leave_type == "unpaid" else staff.base_wage_rate

cl = CostLine.objects.create(
cost_set=cost_set,
kind="time",
desc=f"{label} - {staff.get_display_name()}",
quantity=hours,
unit_cost=wage,
unit_rev=Decimal("0"),
accounting_date=entry_date,
staff=staff,
xero_pay_item=job.default_xero_pay_item,
meta={
"staff_id": str(staff.id),
"date": entry_date.isoformat(),
"is_billable": False,
"created_from_timesheet": True,
"wage_rate_multiplier": 1,
},
)
self.stdout.write(
self.style.SUCCESS(
f"Created: {entry_date} ({entry_date.strftime('%a')}) | "
f"{staff.get_display_name()} | {label} | {hours}h | "
f"${cl.total_cost} | ID: {cl.id}"
if dry_run:
transaction.set_rollback(True)
self.stdout.write(
self.style.WARNING(
f"DRY RUN - all {len(validated)} entries rolled back."
)
)
)
return

self.stdout.write(
self.style.SUCCESS(f"Done. Created {len(validated)} entries.")
Expand Down
Loading
Loading