chore: money-handling + template hygiene (audit items 11-16)
- money filter: format via Decimal(str(...)) instead of float — money never passes through binary floating point on its way to the screen - preview_payslip / worker_lookup_ajax: accumulate totals in Decimal, convert to float only at the JsonResponse boundary, so previews are bit-identical to what _process_single_payment records - price_overtime: explicit .quantize(0.01) instead of letting the DB engine silently round the 3rd decimal place - stacked-chart wage rollup: Decimal(str(salary)) — on SQLite dev the value arrives as float and Decimal(float) keeps the binary noise - payroll_dashboard fmt(): en-ZA space-separated thousands like every other money helper on the page (was the one comma-format outlier) - base.html admin gate: '(auth and staff) or superuser' precedence trap replaced with 'staff or superuser' — exact same truth table (both flags False on AnonymousUser), matches server-side is_admin() - attendance_log.html: worker day-rates now ship via the house json_script pattern instead of |safe-rendering a Python dict repr into the script block Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
541b8973c7
commit
921bdb6b73
@ -404,7 +404,11 @@
|
||||
|
||||
{# === WORK LOG PAYROLL MODAL — click handler + safe DOM builder === #}
|
||||
{# Builds the modal body from JSON via createElement + textContent. #}
|
||||
{% if user.is_authenticated and user.is_staff or user.is_superuser %}
|
||||
{# staff-or-superuser matches the server-side is_admin() helper. The old #}
|
||||
{# "is_authenticated and is_staff or is_superuser" parsed as "(auth AND #}
|
||||
{# staff) OR superuser" — template `and` binds tighter than `or`. Both #}
|
||||
{# flags are False on AnonymousUser, so this simpler form is exact. #}
|
||||
{% if user.is_staff or user.is_superuser %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var modalEl = document.getElementById('workLogPayrollModal');
|
||||
@ -636,7 +640,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
{# === WORK LOG PAYROLL MODAL (admin-only) === #}
|
||||
{# Hidden by default. Any element with data-log-id anywhere in the app #}
|
||||
{# triggers this modal. Fetches JSON and builds the DOM safely. #}
|
||||
{% if user.is_authenticated and user.is_staff or user.is_superuser %}
|
||||
{# staff-or-superuser matches the server-side is_admin() helper. The old #}
|
||||
{# "is_authenticated and is_staff or is_superuser" parsed as "(auth AND #}
|
||||
{# staff) OR superuser" — template `and` binds tighter than `or`. Both #}
|
||||
{# flags are False on AnonymousUser, so this simpler form is exact. #}
|
||||
{% if user.is_staff or user.is_superuser %}
|
||||
<div class="modal fade" id="workLogPayrollModal" tabindex="-1" aria-labelledby="workLogPayrollModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
|
||||
@ -216,6 +216,10 @@
|
||||
</div>
|
||||
|
||||
<!-- === JavaScript: Team auto-select + Cost estimator === -->
|
||||
{# Worker day-rates as a safe JSON island (house json_script pattern — #}
|
||||
{# never |safe-render data into a <script> block). Admin-only, matching #}
|
||||
{# the cost-estimator block below that consumes it. #}
|
||||
{% if is_admin %}{{ worker_rates_json|json_script:"workerRatesData" }}{% endif %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
@ -241,7 +245,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
{% if is_admin %}
|
||||
// === ESTIMATED COST CALCULATOR (Admin Only) ===
|
||||
const workerRates = {{ worker_rates_json|safe }};
|
||||
// Rates come from the #workerRatesData json_script island above —
|
||||
// parsing JSON is XSS-safe regardless of what the data contains.
|
||||
const workerRates = JSON.parse(document.getElementById('workerRatesData').textContent);
|
||||
const startDateInput = document.querySelector('[name="date"]');
|
||||
const endDateInput = document.querySelector('[name="end_date"]');
|
||||
const satCheckbox = document.querySelector('[name="include_saturday"]');
|
||||
|
||||
@ -1413,8 +1413,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const workerChartData = JSON.parse(document.getElementById('workerChartJson').textContent);
|
||||
|
||||
// === HELPER: Format currency ===
|
||||
// en-ZA = space-separated thousands (R 1 234.56) — matches the server's
|
||||
// `money` filter and the formatMoney/formatRand helpers further down.
|
||||
// The old comma regex was the one outlier on the page.
|
||||
function fmt(val) {
|
||||
return 'R ' + parseFloat(val).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return 'R ' + Number(val).toLocaleString('en-ZA', {
|
||||
minimumFractionDigits: 2, maximumFractionDigits: 2
|
||||
});
|
||||
}
|
||||
|
||||
// === HELPER: Create a table cell with text ===
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
# Number formatting filters for South African currency display.
|
||||
# Usage: {% load format_tags %} then {{ value|money }}
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
@ -16,10 +18,13 @@ def money(value):
|
||||
22500 → 22 500.00
|
||||
400.0 → 400.00
|
||||
-300.00 → -300.00
|
||||
|
||||
Formats via Decimal (not float) so money values are never coerced
|
||||
through binary floating point — exact at any magnitude.
|
||||
"""
|
||||
try:
|
||||
num = float(value)
|
||||
except (ValueError, TypeError):
|
||||
num = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
return value
|
||||
|
||||
# Python's :, format gives comma separators — swap commas for spaces
|
||||
|
||||
@ -3245,7 +3245,11 @@ def payroll_dashboard(request):
|
||||
key = (row['project_id'], row['month'].year, row['month'].month)
|
||||
if key not in project_month_wage:
|
||||
continue
|
||||
daily = Decimal(salary) / Decimal('20.00')
|
||||
# Decimal(str(...)) — on SQLite (local dev) .values() returns a
|
||||
# float here, and Decimal(float) keeps the float's binary noise;
|
||||
# str() round-trips cleanly on both backends (MySQL already
|
||||
# returns Decimal, where this is a no-op).
|
||||
daily = Decimal(str(salary)) / Decimal('20.00')
|
||||
project_month_wage[key] += daily * row['worker_count']
|
||||
|
||||
# --- 4. Per-project × per-month paid-adjustment net (ONE GROUP BY) ---
|
||||
@ -4189,7 +4193,12 @@ def price_overtime(request):
|
||||
rate_pct = Decimal(pct)
|
||||
|
||||
# Calculate: daily_rate × overtime_fraction × (rate_percentage / 100)
|
||||
amount = worker.daily_rate * worklog.overtime_amount * (rate_pct / Decimal('100'))
|
||||
# Quantize to cents explicitly — 875.00 × 0.25 × 1.5 gives
|
||||
# 328.125, and without this the DB engine rounds the extra
|
||||
# decimal place silently (engine-dependent half-up/half-even).
|
||||
amount = (
|
||||
worker.daily_rate * worklog.overtime_amount * (rate_pct / Decimal('100'))
|
||||
).quantize(Decimal('0.01'))
|
||||
|
||||
if amount > 0:
|
||||
PayrollAdjustment.objects.create(
|
||||
@ -4824,7 +4833,10 @@ def preview_payslip(request, worker_id):
|
||||
unpaid_logs.sort(key=lambda x: x['date'])
|
||||
|
||||
log_count = len(unpaid_logs)
|
||||
log_amount = float(log_count * worker.daily_rate)
|
||||
# Keep the running totals in Decimal — float() happens only at the
|
||||
# JsonResponse boundary below, so the preview math is bit-identical
|
||||
# to what _process_single_payment will actually record.
|
||||
log_amount = log_count * worker.daily_rate
|
||||
|
||||
# Find pending adjustments — include ID and date for split payslip
|
||||
pending_adjs = worker.adjustments.filter(
|
||||
@ -4832,10 +4844,10 @@ def preview_payslip(request, worker_id):
|
||||
).select_related('project')
|
||||
|
||||
adjustments_list = []
|
||||
adj_total = 0.0
|
||||
adj_total = Decimal('0.00')
|
||||
for adj in pending_adjs:
|
||||
sign = '+' if adj.type in ADDITIVE_TYPES else '-'
|
||||
adj_total += float(adj.amount) if adj.type in ADDITIVE_TYPES else -float(adj.amount)
|
||||
adj_total += adj.amount if adj.type in ADDITIVE_TYPES else -adj.amount
|
||||
adjustments_list.append({
|
||||
'id': adj.id,
|
||||
# 'type' keeps the raw DB value so any JS that uses it as an
|
||||
@ -4893,10 +4905,10 @@ def preview_payslip(request, worker_id):
|
||||
'worker_id_number': worker.id_number,
|
||||
'day_rate': float(worker.daily_rate),
|
||||
'days_worked': log_count,
|
||||
'log_amount': log_amount,
|
||||
'log_amount': float(log_amount),
|
||||
'adjustments': adjustments_list,
|
||||
'adj_total': adj_total,
|
||||
'net_pay': log_amount + adj_total,
|
||||
'adj_total': float(adj_total),
|
||||
'net_pay': float(log_amount + adj_total),
|
||||
'logs': unpaid_logs,
|
||||
'active_loans': loans_list,
|
||||
'pay_period': pay_period,
|
||||
@ -4929,18 +4941,20 @@ def worker_lookup_ajax(request, worker_id):
|
||||
if worker.id not in paid_worker_ids:
|
||||
unpaid_log_count += 1
|
||||
|
||||
log_amount = float(unpaid_log_count * worker.daily_rate)
|
||||
# Decimal accumulation — float() only when the value enters the JSON
|
||||
# response, so the report card matches the payment engine's numbers.
|
||||
log_amount = unpaid_log_count * worker.daily_rate
|
||||
|
||||
# Net adjustment total: additive types increase pay, deductive types decrease it
|
||||
pending_adjs = worker.adjustments.filter(payroll_record__isnull=True)
|
||||
adj_total = 0.0
|
||||
adj_total = Decimal('0.00')
|
||||
for adj in pending_adjs:
|
||||
if adj.type in ADDITIVE_TYPES:
|
||||
adj_total += float(adj.amount)
|
||||
adj_total += adj.amount
|
||||
elif adj.type in DEDUCTIVE_TYPES:
|
||||
adj_total -= float(adj.amount)
|
||||
adj_total -= adj.amount
|
||||
|
||||
amount_payable = log_amount + adj_total
|
||||
amount_payable = float(log_amount + adj_total)
|
||||
|
||||
# === OUTSTANDING LOANS ===
|
||||
# Total remaining balance across all active loans and advances
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user