318 lines
13 KiB
Python
318 lines
13 KiB
Python
from odoo.tests.common import TransactionCase
|
|
|
|
|
|
class TestHrLvAllocation(TransactionCase):
|
|
"""Tests for hr.lv.allocation model methods."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
cls.employee = cls.env.ref("hr.employee_admin")
|
|
cls.calendar = cls.env.ref("resource.resource_calendar_std")
|
|
cls.employee.resource_calendar_id = cls.calendar
|
|
|
|
# Ensure the calendar has attendances marked as effective periods
|
|
# (required by hr_effective_attendance_period for is_working_day etc.)
|
|
Attendance = cls.env["resource.calendar.attendance"]
|
|
existing = Attendance.search([("calendar_id", "=", cls.calendar.id)])
|
|
if existing:
|
|
existing.write({"effective_attendance_period": True})
|
|
else:
|
|
for day_idx, day_name in enumerate(
|
|
["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
|
|
):
|
|
Attendance.create(
|
|
{
|
|
"calendar_id": cls.calendar.id,
|
|
"name": f"{day_name} Morning",
|
|
"dayofweek": str(day_idx),
|
|
"day_period": "morning",
|
|
"hour_from": 8,
|
|
"hour_to": 12,
|
|
"effective_attendance_period": True,
|
|
}
|
|
)
|
|
Attendance.create(
|
|
{
|
|
"calendar_id": cls.calendar.id,
|
|
"name": f"{day_name} Afternoon",
|
|
"dayofweek": str(day_idx),
|
|
"day_period": "afternoon",
|
|
"hour_from": 13,
|
|
"hour_to": 17,
|
|
"effective_attendance_period": True,
|
|
}
|
|
)
|
|
|
|
cls.allocation = cls._build_allocation(cls)
|
|
|
|
# Ensure a cancelling event type exists
|
|
cls.cancelling_categ = cls.env.ref(
|
|
"hr_luncheon_voucher.categ_meet_free_lunch",
|
|
raise_if_not_found=False,
|
|
)
|
|
if not cls.cancelling_categ:
|
|
cls.cancelling_categ = cls.env["calendar.event.type"].create(
|
|
{"name": "Free Lunch Test", "remove_luncheon_voucher": True}
|
|
)
|
|
else:
|
|
cls.cancelling_categ.remove_luncheon_voucher = True
|
|
|
|
# ------------------------------------------------------------------
|
|
# Helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _build_allocation(self, **overrides):
|
|
vals = {
|
|
"employee_id": self.employee.id,
|
|
"distrib_campaign_name": "Test Campaign",
|
|
"date_from": "2026-01-01",
|
|
"date_to": "2026-01-31",
|
|
"name": "Test Campaign - Admin",
|
|
}
|
|
vals.update(overrides)
|
|
return self.env["hr.lv.allocation"].create(vals)
|
|
|
|
# ------------------------------------------------------------------
|
|
# _compute_lv_balance
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_lv_balance_positive(self):
|
|
"""Balance = number_dued_lv - number_distributed_lv (positive)."""
|
|
alloc = self._build_allocation()
|
|
alloc.write(
|
|
{"number_dued_lv": 10, "number_distributed_lv": 3}
|
|
)
|
|
alloc._compute_lv_balance()
|
|
self.assertEqual(alloc.lv_balance, 7)
|
|
|
|
def test_lv_balance_zero(self):
|
|
"""Balance is 0 when due equals distributed."""
|
|
alloc = self._build_allocation()
|
|
alloc.write(
|
|
{"number_dued_lv": 5, "number_distributed_lv": 5}
|
|
)
|
|
alloc._compute_lv_balance()
|
|
self.assertEqual(alloc.lv_balance, 0)
|
|
|
|
def test_lv_balance_negative(self):
|
|
"""Balance can be negative (over-distribution)."""
|
|
alloc = self._build_allocation()
|
|
alloc.write(
|
|
{"number_dued_lv": 2, "number_distributed_lv": 5}
|
|
)
|
|
alloc._compute_lv_balance()
|
|
self.assertEqual(alloc.lv_balance, -3)
|
|
|
|
# ------------------------------------------------------------------
|
|
# _has_cancelling_voucher_event
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_has_cancelling_event_true(self):
|
|
"""Returns True when a cancelling event exists for the employee."""
|
|
partner = self.employee.user_id.partner_id
|
|
self.env["calendar.event"].create(
|
|
{
|
|
"name": "Team Lunch",
|
|
"start": "2026-01-15 12:00:00",
|
|
"stop": "2026-01-15 14:00:00",
|
|
"categ_ids": [(6, 0, self.cancelling_categ.ids)],
|
|
"partner_ids": [(4, partner.id)],
|
|
}
|
|
)
|
|
day = self.env.cr.now().replace(day=15, month=1, year=2026)
|
|
self.assertTrue(self.allocation._has_cancelling_voucher_event(day))
|
|
|
|
def test_has_cancelling_event_false_no_event(self):
|
|
"""Returns False when no cancelling event exists."""
|
|
day = self.env.cr.now().replace(day=10, month=1, year=2026)
|
|
self.assertFalse(self.allocation._has_cancelling_voucher_event(day))
|
|
|
|
def test_has_cancelling_event_false_non_cancelling_category(self):
|
|
"""Returns False when events exist but without the cancelling flag."""
|
|
categ = self.env["calendar.event.type"].create(
|
|
{"name": "Regular Meeting", "remove_luncheon_voucher": False}
|
|
)
|
|
partner = self.employee.user_id.partner_id
|
|
self.env["calendar.event"].create(
|
|
{
|
|
"name": "Stand-up",
|
|
"start": "2026-01-15 09:00:00",
|
|
"stop": "2026-01-15 09:30:00",
|
|
"categ_ids": [(6, 0, categ.ids)],
|
|
"partner_ids": [(4, partner.id)],
|
|
}
|
|
)
|
|
day = self.env.cr.now().replace(day=15, month=1, year=2026)
|
|
self.assertFalse(self.allocation._has_cancelling_voucher_event(day))
|
|
|
|
def test_has_cancelling_event_outside_day(self):
|
|
"""Returns False when the event is entirely outside the target day."""
|
|
partner = self.employee.user_id.partner_id
|
|
self.env["calendar.event"].create(
|
|
{
|
|
"name": "Late Dinner",
|
|
"start": "2026-01-15 22:00:00",
|
|
"stop": "2026-01-16 00:30:00",
|
|
"categ_ids": [(6, 0, self.cancelling_categ.ids)],
|
|
"partner_ids": [(4, partner.id)],
|
|
}
|
|
)
|
|
# Check a different day
|
|
day = self.env.cr.now().replace(day=14, month=1, year=2026)
|
|
self.assertFalse(self.allocation._has_cancelling_voucher_event(day))
|
|
|
|
# ------------------------------------------------------------------
|
|
# _calculate_number_acquired_lv
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_calculate_acquired_on_non_working_day(self):
|
|
"""Acquired is 0 for a period of non-working days (e.g. weekend-only)."""
|
|
alloc = self._build_allocation(
|
|
date_from="2026-01-03", # Saturday
|
|
date_to="2026-01-04", # Sunday
|
|
)
|
|
alloc._calculate_number_acquired_lv()
|
|
self.assertEqual(alloc.number_acquired_lv, 0)
|
|
|
|
def test_calculate_acquired_basic(self):
|
|
"""Acquired is > 0 for a normal working period (employee works)."""
|
|
alloc = self._build_allocation(
|
|
date_from="2026-01-05", # Monday
|
|
date_to="2026-01-09", # Friday
|
|
)
|
|
alloc._calculate_number_acquired_lv()
|
|
# Standard 5-day calendar: 5 working days, no leaves, no events
|
|
self.assertEqual(alloc.number_acquired_lv, 5)
|
|
|
|
def test_calculate_acquired_with_leave(self):
|
|
"""Acquired excludes days covered by leave."""
|
|
# Create a leave covering Wednesday 2026-01-07
|
|
self.env["resource.calendar.leaves"].create(
|
|
{
|
|
"name": "Sick leave",
|
|
"date_from": "2026-01-07 00:00:00",
|
|
"date_to": "2026-01-07 23:59:59",
|
|
"calendar_id": self.calendar.id,
|
|
"resource_id": self.employee.resource_id.id,
|
|
}
|
|
)
|
|
alloc = self._build_allocation(
|
|
date_from="2026-01-05", # Monday
|
|
date_to="2026-01-09", # Friday
|
|
)
|
|
alloc._calculate_number_acquired_lv()
|
|
# 5 working days - 1 leave = 4
|
|
self.assertEqual(alloc.number_acquired_lv, 4)
|
|
|
|
def test_calculate_acquired_with_half_day_cancel(self):
|
|
"""When hr_half_day_cancels_voucher is enabled, partial days are excluded."""
|
|
self.env.company.hr_half_day_cancels_voucher = True
|
|
# Modify calendar to have only morning attendance on Wednesday
|
|
wed_attendance = self.env["resource.calendar.attendance"].search(
|
|
[
|
|
("calendar_id", "=", self.calendar.id),
|
|
("dayofweek", "=", "2"), # Wednesday
|
|
("day_period", "=", "afternoon"),
|
|
]
|
|
)
|
|
wed_attendance.unlink()
|
|
|
|
alloc = self._build_allocation(
|
|
date_from="2026-01-05", # Monday
|
|
date_to="2026-01-09", # Friday
|
|
)
|
|
alloc._calculate_number_acquired_lv()
|
|
# 4 full days + 1 half-day excluded = 4
|
|
self.assertEqual(alloc.number_acquired_lv, 4)
|
|
|
|
# ------------------------------------------------------------------
|
|
# _calculate_number_dued_lv
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_calculate_dued_draft(self):
|
|
"""In non-distributed state: dued = employee.dued_lv + number_acquired_lv."""
|
|
self.employee.write({"dued_lv": 5})
|
|
alloc = self._build_allocation(state="draft")
|
|
alloc.number_acquired_lv = 10
|
|
alloc._calculate_number_dued_lv()
|
|
self.assertEqual(alloc.number_dued_lv, 15)
|
|
|
|
def test_calculate_dued_distributed(self):
|
|
"""In distributed: dued = employee.dued_lv only (no addition)."""
|
|
self.employee.write({"dued_lv": 8})
|
|
alloc = self._build_allocation(state="distributed")
|
|
alloc.number_acquired_lv = 20
|
|
alloc._calculate_number_dued_lv()
|
|
self.assertEqual(alloc.number_dued_lv, 8)
|
|
|
|
# ------------------------------------------------------------------
|
|
# confirm_allocation
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_confirm_draft(self):
|
|
"""confirm_allocation transitions draft → confirmed and updates counters."""
|
|
self.employee.write({"total_acquired_lv": 0, "dued_lv": 0})
|
|
alloc = self._build_allocation(state="draft")
|
|
alloc.number_acquired_lv = 6
|
|
alloc.confirm_allocation()
|
|
self.assertEqual(alloc.state, "confirmed")
|
|
self.employee.refresh_lv_values()
|
|
self.assertEqual(self.employee.total_acquired_lv, 6)
|
|
|
|
def test_confirm_already_confirmed_does_not_change(self):
|
|
"""confirm_allocation on a confirmed record is a no-op."""
|
|
alloc = self._build_allocation(state="confirmed")
|
|
alloc.confirm_allocation()
|
|
self.assertEqual(alloc.state, "confirmed")
|
|
|
|
# ------------------------------------------------------------------
|
|
# back_to_draft
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_back_to_draft_from_confirmed(self):
|
|
"""back_to_draft transitions confirmed → draft."""
|
|
alloc = self._build_allocation(state="confirmed")
|
|
alloc.back_to_draft()
|
|
self.assertEqual(alloc.state, "draft")
|
|
|
|
def test_back_to_draft_from_distributed(self):
|
|
"""back_to_draft transitions distributed → draft."""
|
|
alloc = self._build_allocation(state="distributed")
|
|
alloc.back_to_draft()
|
|
self.assertEqual(alloc.state, "draft")
|
|
|
|
def test_back_to_draft_draft_is_noop(self):
|
|
"""back_to_draft on a draft record is a no-op."""
|
|
alloc = self._build_allocation(state="draft")
|
|
alloc.back_to_draft()
|
|
self.assertEqual(alloc.state, "draft")
|
|
|
|
# ------------------------------------------------------------------
|
|
# distribute_allocation
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_distribute_confirmed(self):
|
|
"""distribute_allocation transitions confirmed → distributed."""
|
|
alloc = self._build_allocation(state="confirmed")
|
|
alloc.distribute_allocation()
|
|
self.assertEqual(alloc.state, "distributed")
|
|
|
|
def test_distribute_draft_is_noop(self):
|
|
"""distribute_allocation on draft is a no-op."""
|
|
alloc = self._build_allocation(state="draft")
|
|
alloc.distribute_allocation()
|
|
self.assertEqual(alloc.state, "draft")
|
|
|
|
# ------------------------------------------------------------------
|
|
# adjust_distribution
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_adjust_distribution(self):
|
|
"""Adjust distribution sets number_distributed_lv = dued + acquired."""
|
|
alloc = self._build_allocation(state="draft")
|
|
alloc.number_acquired_lv = 5
|
|
alloc.employee_id.dued_lv = 3
|
|
alloc.adjust_distribution()
|
|
self.assertEqual(alloc.number_distributed_lv, 8)
|