diff --git a/.github/helper/apps.json b/.github/helper/apps.json new file mode 100644 index 0000000000..db4b0e3fd4 --- /dev/null +++ b/.github/helper/apps.json @@ -0,0 +1,5 @@ +[ + {"url": "https://github.com/frappe/erpnext","branch": "version-15"}, + {"url": "https://github.com/frappe/payments","branch": "version-15"}, + {"url": "https://github.com/frappe/hrms","branch": "version-15"} +] \ No newline at end of file diff --git a/.github/workflows/build_image.yml b/.github/workflows/build_image.yml new file mode 100644 index 0000000000..76aa8c4986 --- /dev/null +++ b/.github/workflows/build_image.yml @@ -0,0 +1,66 @@ +name: Build Container Image +on: + release: + types: [published] + workflow_dispatch: + push: + branches: + - version-15 + tags: + - "*" +jobs: + build: + name: Build + runs-on: ubuntu-latest + + strategy: + matrix: + arch: [amd64, arm64] + + permissions: + packages: write + + steps: + - name: Checkout Entire Repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + platforms: linux/${{ matrix.arch }} + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set Branch + run: | + export APPS_JSON_PATH='${{ github.workspace }}/.github/helper/apps.json' + echo "APPS_JSON_BASE64=$(cat $APPS_JSON_PATH | base64 -w 0)" >> $GITHUB_ENV + echo "FRAPPE_BRANCH=version-15" >> $GITHUB_ENV + + - name: Set Image Tag + run: | + echo "IMAGE_TAG=stable" >> $GITHUB_ENV + - uses: actions/checkout@v4 + with: + repository: frappe/frappe_docker + path: builds + + - name: Build and push + uses: docker/build-push-action@v6 + with: + push: true + context: builds + file: builds/images/layered/Containerfile + tags: > + ghcr.io/${{ github.repository }}:${{ github.ref_name }}, + ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }} + build-args: | + "FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}" + "APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cc6649f62..8517043028 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,7 @@ jobs: run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts - name: Cache pip - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }} @@ -79,7 +79,7 @@ jobs: ${{ runner.os }}- - name: Cache node modules - uses: actions/cache@v2 + uses: actions/cache@v4 env: cache-name: cache-node-modules with: @@ -94,7 +94,7 @@ jobs: id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - - uses: actions/cache@v2 + - uses: actions/cache@v4 id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -115,7 +115,7 @@ jobs: CAPTURE_COVERAGE: ${{ github.event_name != 'pull_request' }} - name: Upload coverage data - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: github.event_name != 'pull_request' with: name: coverage-${{ matrix.container }} @@ -131,7 +131,7 @@ jobs: uses: actions/checkout@v2 - name: Download artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 - name: Upload coverage data uses: codecov/codecov-action@v2 diff --git a/frappe-ui b/frappe-ui index 3613b49380..863eaae9ad 160000 --- a/frappe-ui +++ b/frappe-ui @@ -1 +1 @@ -Subproject commit 3613b4938068588483699a82b40f7cdd604cf912 +Subproject commit 863eaae9ada2edb287fc09fb21d05212bb5eebe9 diff --git a/frontend/package.json b/frontend/package.json index 62e5a7da20..fc5685b567 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,7 +18,7 @@ "dayjs": "^1.11.11", "feather-icons": "^4.29.1", "firebase": "^10.8.0", - "frappe-ui": "^0.1.72", + "frappe-ui": "0.1.105", "postcss": "^8.4.5", "tailwindcss": "^3.4.3", "vite": "^5.4.10", diff --git a/frontend/src/components/CheckInPanel.vue b/frontend/src/components/CheckInPanel.vue index 2282ae40a7..19cdcb83fa 100644 --- a/frontend/src/components/CheckInPanel.vue +++ b/frontend/src/components/CheckInPanel.vue @@ -69,7 +69,7 @@ - diff --git a/frontend/src/components/ExpenseClaimItem.vue b/frontend/src/components/ExpenseClaimItem.vue index 87499824f2..cceb4c7762 100644 --- a/frontend/src/components/ExpenseClaimItem.vue +++ b/frontend/src/components/ExpenseClaimItem.vue @@ -11,12 +11,10 @@ {{ claimTitle }}
- - {{ formatCurrency(props.doc.total_claimed_amount, currency) }} - + {{ claimDates }} · - {{ claimDates }} + {{ formatCurrency(props.doc.total_claimed_amount, currency) }}
diff --git a/frontend/src/utils/dayjs.js b/frontend/src/utils/dayjs.js index 922be22a83..6fb884a67a 100644 --- a/frontend/src/utils/dayjs.js +++ b/frontend/src/utils/dayjs.js @@ -1,7 +1,7 @@ import dayjs from "dayjs" import updateLocale from "dayjs/plugin/updateLocale" import localizedFormat from "dayjs/plugin/localizedFormat" -import relativeTime from "dayjs/esm/plugin/relativeTime" +import relativeTime from "dayjs/plugin/relativeTime" import isToday from "dayjs/plugin/isToday" import isYesterday from "dayjs/plugin/isYesterday" import isBetween from "dayjs/plugin/isBetween" diff --git a/frontend/src/views/Notifications.vue b/frontend/src/views/Notifications.vue index 554378bc3c..54ead03fe8 100644 --- a/frontend/src/views/Notifications.vue +++ b/frontend/src/views/Notifications.vue @@ -102,7 +102,6 @@ import { arePushNotificationsEnabled, } from "@/data/notifications" -const user = inject("$user") const dayjs = inject("$dayjs") const router = useRouter() diff --git a/hrms/__init__.py b/hrms/__init__.py index 3df85a9610..dce6b904a4 100644 --- a/hrms/__init__.py +++ b/hrms/__init__.py @@ -1,6 +1,6 @@ import frappe -__version__ = "15.36.1" +__version__ = "15.39.0" def refetch_resource(cache_key: str | list, user=None): diff --git a/hrms/api/roster.py b/hrms/api/roster.py index fa26bc4ef6..3e15d419f2 100644 --- a/hrms/api/roster.py +++ b/hrms/api/roster.py @@ -6,6 +6,7 @@ from hrms.hr.doctype.shift_assignment.shift_assignment import ShiftAssignment from hrms.hr.doctype.shift_assignment_tool.shift_assignment_tool import create_shift_assignment +from hrms.hr.doctype.shift_schedule.shift_schedule import get_or_insert_shift_schedule @frappe.whitelist() @@ -37,7 +38,17 @@ def get_events( @frappe.whitelist() -def create_shift_assignment_schedule( +def get_schedule_from_assignment(shift_schedule_assignment: str): + shift_schedule = frappe.db.get_value( + "Shift Schedule Assignment", shift_schedule_assignment, "shift_schedule" + ) + frequency = frappe.db.get_value("Shift Schedule", shift_schedule, "frequency") + repeat_on_days = frappe.get_all("Assignment Rule Day", filters={"parent": shift_schedule}, pluck="day") + return {"frequency": frequency, "repeat_on_days": repeat_on_days} + + +@frappe.whitelist() +def create_shift_schedule_assignment( employee: str, company: str, shift_type: str, @@ -46,34 +57,39 @@ def create_shift_assignment_schedule( end_date: str | None, repeat_on_days: list[str], frequency: str, + shift_location: str | None = None, ) -> None: - schedule = frappe.get_doc( + shift_schedule = get_or_insert_shift_schedule(shift_type, frequency, repeat_on_days) + shift_schedule_assignment = frappe.get_doc( { - "doctype": "Shift Assignment Schedule", - "frequency": frequency, - "repeat_on_days": [{"day": day} for day in repeat_on_days], - "enabled": 0 if end_date else 1, + "doctype": "Shift Schedule Assignment", + "shift_schedule": shift_schedule, "employee": employee, "company": company, - "shift_type": shift_type, "shift_status": status, + "shift_location": shift_location, + "enabled": 0 if end_date else 1, } ).insert() if not end_date or date_diff(end_date, start_date) <= 90: - return schedule.create_shifts(start_date, end_date) + return shift_schedule_assignment.create_shifts(start_date, end_date) - frappe.enqueue(schedule.create_shifts, timeout=4500, start_date=start_date, end_date=end_date) + frappe.enqueue( + shift_schedule_assignment.create_shifts, timeout=4500, start_date=start_date, end_date=end_date + ) @frappe.whitelist() -def delete_shift_assignment_schedule(schedule: str) -> None: - for shift_assignment in frappe.get_all("Shift Assignment", {"schedule": schedule}, pluck="name"): +def delete_shift_schedule_assignment(shift_schedule_assignment: str) -> None: + for shift_assignment in frappe.get_all( + "Shift Assignment", {"shift_schedule_assignment": shift_schedule_assignment}, pluck="name" + ): doc = frappe.get_doc("Shift Assignment", shift_assignment) if doc.docstatus == 1: doc.cancel() frappe.delete_doc("Shift Assignment", shift_assignment) - frappe.delete_doc("Shift Assignment Schedule", schedule) + frappe.delete_doc("Shift Schedule Assignment", shift_schedule_assignment) @frappe.whitelist() @@ -93,7 +109,13 @@ def swap_shift( src_shift_doc = frappe.get_doc("Shift Assignment", src_shift) break_shift(src_shift_doc, src_date) insert_shift( - tgt_employee, tgt_company, src_shift_doc.shift_type, tgt_date, tgt_date, src_shift_doc.status + tgt_employee, + tgt_company, + src_shift_doc.shift_type, + tgt_date, + tgt_date, + src_shift_doc.status, + src_shift_doc.shift_location, ) if tgt_shift: @@ -104,6 +126,7 @@ def swap_shift( src_date, src_date, tgt_shift_doc.status, + tgt_shift_doc.shift_location, ) @@ -122,6 +145,7 @@ def break_shift(assignment: str | ShiftAssignment, date: str) -> None: shift_type = assignment.shift_type status = assignment.status end_date = assignment.end_date + shift_location = assignment.shift_location if date_diff(date, assignment.start_date) == 0: assignment.cancel() @@ -131,12 +155,20 @@ def break_shift(assignment: str | ShiftAssignment, date: str) -> None: assignment.save() if not end_date or date_diff(end_date, date) > 0: - create_shift_assignment(employee, company, shift_type, add_days(date, 1), end_date, status) + create_shift_assignment( + employee, company, shift_type, add_days(date, 1), end_date, status, shift_location + ) @frappe.whitelist() def insert_shift( - employee: str, company: str, shift_type: str, start_date: str, end_date: str | None, status: str + employee: str, + company: str, + shift_type: str, + start_date: str, + end_date: str | None, + status: str, + shift_location: str | None = None, ) -> None: filters = { "doctype": "Shift Assignment", @@ -144,6 +176,7 @@ def insert_shift( "company": company, "shift_type": shift_type, "status": status, + "shift_location": shift_location, } prev_shift = frappe.db.exists(dict({"end_date": add_days(start_date, -1)}, **filters)) next_shift = ( @@ -161,7 +194,7 @@ def insert_shift( frappe.db.set_value("Shift Assignment", next_shift, "start_date", start_date) else: - create_shift_assignment(employee, company, shift_type, start_date, end_date, status) + create_shift_assignment(employee, company, shift_type, start_date, end_date, status, shift_location) def get_holidays(month_start: str, month_end: str, employee_filters: dict[str, str]) -> dict[str, list[dict]]: @@ -223,9 +256,11 @@ def get_shifts( ShiftAssignment.name, ShiftAssignment.employee, ShiftAssignment.shift_type, + ShiftAssignment.shift_location, ShiftAssignment.start_date, ShiftAssignment.end_date, ShiftAssignment.status, + ShiftAssignment.shift_schedule_assignment, ShiftType.start_time, ShiftType.end_time, ShiftType.color, diff --git a/hrms/hooks.py b/hrms/hooks.py index 1c8abcda72..c9c54d1b4b 100644 --- a/hrms/hooks.py +++ b/hrms/hooks.py @@ -168,6 +168,7 @@ "hrms.overrides.company.make_company_fixtures", "hrms.overrides.company.set_default_hr_accounts", ], + "on_trash": "hrms.overrides.company.handle_linked_docs", }, "Holiday List": { "on_update": "hrms.utils.holiday_list.invalidate_cache", @@ -221,7 +222,7 @@ ], "hourly_long": [ "hrms.hr.doctype.shift_type.shift_type.process_auto_attendance_for_all_shifts", - "hrms.hr.doctype.shift_assignment_schedule.shift_assignment_schedule.process_auto_shift_creation", + "hrms.hr.doctype.shift_schedule_assignment.shift_schedule_assignment.process_auto_shift_creation", ], "daily": [ "hrms.controllers.employee_reminders.send_birthday_reminders", @@ -347,3 +348,15 @@ # Recommended only for DocTypes which have limited documents with untranslated names # For example: Role, Gender, etc. # translated_search_doctypes = [] + +company_data_to_be_ignored = [ + "Salary Component Account", + "Salary Structure", + "Salary Structure Assignment", + "Payroll Period", + "Income Tax Slab", + "Leave Period", + "Leave Policy Assignment", + "Employee Onboarding Template", + "Employee Separation Template", +] diff --git a/hrms/hr/doctype/compensatory_leave_request/compensatory_leave_request.py b/hrms/hr/doctype/compensatory_leave_request/compensatory_leave_request.py index 7302e2ddd1..791e5295f0 100644 --- a/hrms/hr/doctype/compensatory_leave_request/compensatory_leave_request.py +++ b/hrms/hr/doctype/compensatory_leave_request/compensatory_leave_request.py @@ -1,6 +1,7 @@ # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +import datetime import frappe from frappe import _ @@ -78,7 +79,7 @@ def on_submit(self): comp_leave_valid_from = add_days(self.work_end_date, 1) leave_period = get_leave_period(comp_leave_valid_from, comp_leave_valid_from, company) if leave_period: - leave_allocation = self.get_existing_allocation_for_period(leave_period) + leave_allocation = self.get_existing_allocation(comp_leave_valid_from) if leave_allocation: leave_allocation.new_leaves_allocated += date_difference leave_allocation.validate() @@ -122,30 +123,21 @@ def on_cancel(self): leave_allocation, date_difference * -1, add_days(self.work_end_date, 1) ) - def get_existing_allocation_for_period(self, leave_period): - leave_allocation = frappe.db.sql( - """ - select name - from `tabLeave Allocation` - where employee=%(employee)s and leave_type=%(leave_type)s - and docstatus=1 - and (from_date between %(from_date)s and %(to_date)s - or to_date between %(from_date)s and %(to_date)s - or (from_date < %(from_date)s and to_date > %(to_date)s)) - """, - { - "from_date": leave_period[0].from_date, - "to_date": leave_period[0].to_date, + def get_existing_allocation(self, comp_leave_valid_from: datetime.date) -> dict | None: + leave_allocation = frappe.db.get_all( + "Leave Allocation", + filters={ "employee": self.employee, "leave_type": self.leave_type, + "from_date": ("<=", comp_leave_valid_from), + "to_date": (">=", comp_leave_valid_from), + "docstatus": 1, }, - as_dict=1, + limit=1, ) if leave_allocation: return frappe.get_doc("Leave Allocation", leave_allocation[0].name) - else: - return False def create_leave_allocation(self, leave_period, date_difference): is_carry_forward = frappe.db.get_value("Leave Type", self.leave_type, "is_carry_forward") diff --git a/hrms/hr/doctype/compensatory_leave_request/test_compensatory_leave_request.py b/hrms/hr/doctype/compensatory_leave_request/test_compensatory_leave_request.py index c05393a000..40b568ed34 100644 --- a/hrms/hr/doctype/compensatory_leave_request/test_compensatory_leave_request.py +++ b/hrms/hr/doctype/compensatory_leave_request/test_compensatory_leave_request.py @@ -3,15 +3,14 @@ import frappe from frappe.tests.utils import FrappeTestCase -from frappe.utils import add_days, add_months, today +from frappe.utils import add_days, add_months, getdate, today from hrms.hr.doctype.attendance_request.test_attendance_request import get_employee +from hrms.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation from hrms.hr.doctype.leave_application.leave_application import get_leave_balance_on from hrms.hr.doctype.leave_period.test_leave_period import create_leave_period from hrms.tests.test_utils import add_date_to_holiday_list -test_dependencies = ["Employee"] - class TestCompensatoryLeaveRequest(FrappeTestCase): def setUp(self): @@ -42,7 +41,7 @@ def test_leave_balance_on_submit(self): before + 1, ) - def test_leave_allocation_update_on_submit(self): + def test_allocation_update_on_submit(self): employee = get_employee() mark_attendance(employee, date=add_days(today(), -1)) compensatory_leave_request = get_compensatory_leave_request( @@ -70,6 +69,54 @@ def test_leave_allocation_update_on_submit(self): ) self.assertEqual(leaves_allocated, 2) + def test_allocation_update_on_submit_on_multiple_allocations(self): + """Tests whether the correct allocation is updated when there are multiple allocations in the same leave period""" + employee = get_employee() + today = getdate() + + first_alloc_start = add_months(today, -3) + first_alloc_end = add_days(today, -1) + second_alloc_start = today + second_alloc_end = add_months(today, 1) + + add_date_to_holiday_list(first_alloc_start, employee.holiday_list) + allocation_1 = create_leave_allocation( + leave_type="Compensatory Off", + employee=employee.name, + from_date=first_alloc_start, + to_date=first_alloc_end, + ) + allocation_1.new_leaves_allocated = 0 + allocation_1.submit() + + add_date_to_holiday_list(second_alloc_start, employee.holiday_list) + allocation_2 = create_leave_allocation( + leave_type="Compensatory Off", + employee=employee.name, + from_date=second_alloc_start, + to_date=second_alloc_end, + ) + allocation_2.new_leaves_allocated = 0 + allocation_2.submit() + + # adds leave balance in first allocation + mark_attendance(employee, date=first_alloc_start) + compensatory_leave_request = get_compensatory_leave_request( + employee.name, leave_date=first_alloc_start + ) + compensatory_leave_request.submit() + allocation_1.reload() + self.assertEqual(allocation_1.total_leaves_allocated, 1) + + # adds leave balance in second allocation + mark_attendance(employee, date=second_alloc_start) + compensatory_leave_request = get_compensatory_leave_request( + employee.name, leave_date=second_alloc_start + ) + compensatory_leave_request.submit() + allocation_2.reload() + self.assertEqual(allocation_2.total_leaves_allocated, 1) + def test_creation_of_leave_ledger_entry_on_submit(self): """check creation of leave ledger entry on submission of leave request""" employee = get_employee() diff --git a/hrms/hr/doctype/employee_advance/employee_advance.js b/hrms/hr/doctype/employee_advance/employee_advance.js index 54f9a117ee..1764d2d922 100644 --- a/hrms/hr/doctype/employee_advance/employee_advance.js +++ b/hrms/hr/doctype/employee_advance/employee_advance.js @@ -138,6 +138,7 @@ frappe.ui.form.on("Employee Advance", { posting_date: frm.doc.posting_date, paid_amount: frm.doc.paid_amount, claimed_amount: frm.doc.claimed_amount, + return_amount: frm.doc.return_amount, }, callback: function (r) { const doclist = frappe.model.sync(r.message); diff --git a/hrms/hr/doctype/employee_advance/employee_advance.json b/hrms/hr/doctype/employee_advance/employee_advance.json index 1ad2cc9867..48f86413e9 100644 --- a/hrms/hr/doctype/employee_advance/employee_advance.json +++ b/hrms/hr/doctype/employee_advance/employee_advance.json @@ -173,7 +173,7 @@ }, { "allow_on_submit": 1, - "description": "Amount returned by the employee after the advance is paid", + "description": "Amount scheduled for deduction via salary", "fieldname": "return_amount", "fieldtype": "Currency", "label": "Returned Amount", @@ -241,7 +241,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2024-04-12 13:53:55.442187", + "modified": "2025-01-29 12:05:13.623633", "modified_by": "Administrator", "module": "HR", "name": "Employee Advance", diff --git a/hrms/hr/doctype/employee_advance/test_employee_advance.py b/hrms/hr/doctype/employee_advance/test_employee_advance.py index 7e9de8db9a..23073d6054 100644 --- a/hrms/hr/doctype/employee_advance/test_employee_advance.py +++ b/hrms/hr/doctype/employee_advance/test_employee_advance.py @@ -14,7 +14,7 @@ make_bank_entry, make_return_entry, ) -from hrms.hr.doctype.expense_claim.expense_claim import get_advances +from hrms.hr.doctype.expense_claim.expense_claim import get_advances, get_allocation_amount from hrms.hr.doctype.expense_claim.test_expense_claim import ( get_payable_account, make_expense_claim, @@ -353,7 +353,11 @@ def get_advances_for_claim(claim, advance_name, amount=None): if amount: allocated_amount = amount else: - allocated_amount = flt(entry.paid_amount) - flt(entry.claimed_amount) + allocated_amount = get_allocation_amount( + paid_amount=entry.paid_amount, + claimed_amount=entry.claimed_amount, + return_amount=entry.return_amount, + ) claim.append( "advances", @@ -362,7 +366,8 @@ def get_advances_for_claim(claim, advance_name, amount=None): "posting_date": entry.posting_date, "advance_account": entry.advance_account, "advance_paid": entry.paid_amount, - "unclaimed_amount": allocated_amount, + "return_amount": entry.return_amount, + "unclaimed_amount": entry.paid_amount - entry.claimed_amount, "allocated_amount": allocated_amount, }, ) diff --git a/hrms/hr/doctype/employee_checkin/employee_checkin.js b/hrms/hr/doctype/employee_checkin/employee_checkin.js index 2d86442b1f..9b9a2a98c3 100644 --- a/hrms/hr/doctype/employee_checkin/employee_checkin.js +++ b/hrms/hr/doctype/employee_checkin/employee_checkin.js @@ -23,22 +23,27 @@ frappe.ui.form.on("Employee Checkin", { add_fetch_shift_button(frm) { if (frm.doc.attendace) return; frm.add_custom_button(__("Fetch Shift"), function () { - const previous_shift = frm.doc.shift; frappe.call({ method: "fetch_shift", doc: frm.doc, freeze: true, freeze_message: __("Fetching Shift"), callback: function () { - if (previous_shift === frm.doc.shift) return; - frm.dirty(); - frm.save(); - frappe.show_alert({ - message: __("Shift has been successfully updated to {0}.", [ - frm.doc.shift, - ]), - indicator: "green", - }); + if (frm.doc.shift) { + frappe.show_alert({ + message: __("Shift has been successfully updated to {0}.", [ + frm.doc.shift, + ]), + indicator: "green", + }); + frm.dirty(); + frm.save(); + } else { + frappe.show_alert({ + message: __("No valid shift found for log time"), + indicator: "orange", + }); + } }, }); }); diff --git a/hrms/hr/doctype/employee_checkin/test_employee_checkin.py b/hrms/hr/doctype/employee_checkin/test_employee_checkin.py index 98f7388a6d..79371a7583 100644 --- a/hrms/hr/doctype/employee_checkin/test_employee_checkin.py +++ b/hrms/hr/doctype/employee_checkin/test_employee_checkin.py @@ -583,6 +583,34 @@ def test_bulk_fetch_shift(self): # shift does not change since attendance is already marked self.assertEqual(log2.shift, shift1.name) + def test_bulk_fetch_shift_if_shift_settings_change_for_the_same_shift(self): + emp1 = make_employee("bulkemp1@example.com", company="_Test Company") + emp2 = make_employee("bulkemp2@example.com", company="_Test Company") + + # 8 - 12, + shift = setup_shift_type(shift_type="Test Bulk Shift") + date = getdate() + make_shift_assignment(shift.name, emp1, date) + make_shift_assignment(shift.name, emp2, date) + + timestamp = datetime.combine(date, get_time("08:00:00")) + # shift actual start is `current date 07:00:00` + log1 = make_checkin(emp1, timestamp) + self.assertEqual(log1.shift_actual_start, datetime.combine(date, get_time("07:00:00"))) + log2 = make_checkin(emp2, timestamp) + self.assertEqual(log2.shift_actual_start, datetime.combine(date, get_time("07:00:00"))) + + # change shift settings like check in buffer from 60 minutes to 120 minutes + # so now shift actual start is `current date 06:00:00` + shift.begin_check_in_before_shift_start_time = 120 + shift.save() + bulk_fetch_shift([log1.name, log2.name]) + # shift changes according to the new assignment + log1.reload() + self.assertEqual(log1.shift_actual_start, datetime.combine(date, get_time("06:00:00"))) + log2.reload() + self.assertEqual(log2.shift_actual_start, datetime.combine(date, get_time("06:00:00"))) + def make_n_checkins(employee, n, hours_to_reverse=1): logs = [make_checkin(employee, now_datetime() - timedelta(hours=hours_to_reverse, minutes=n + 1))] diff --git a/hrms/hr/doctype/expense_claim/expense_claim.js b/hrms/hr/doctype/expense_claim/expense_claim.js index 0f826807bd..f0658dbb10 100644 --- a/hrms/hr/doctype/expense_claim/expense_claim.js +++ b/hrms/hr/doctype/expense_claim/expense_claim.js @@ -64,6 +64,14 @@ frappe.ui.form.on("Expense Claim", { query: "erpnext.controllers.queries.employee_query", }; }); + + frm.set_query("department", function () { + return { + filters: { + company: frm.doc.company, + }, + }; + }); }, onload: function (frm) { @@ -194,8 +202,9 @@ frappe.ui.form.on("Expense Claim", { update_employee_advance_claimed_amount: function (frm) { let amount_to_be_allocated = frm.doc.grand_total; $.each(frm.doc.advances || [], function (i, advance) { - if (amount_to_be_allocated >= advance.unclaimed_amount) { - advance.allocated_amount = frm.doc.advances[i].unclaimed_amount; + if (amount_to_be_allocated >= advance.unclaimed_amount - advance.return_amount) { + advance.allocated_amount = + frm.doc.advances[i].unclaimed_amount - frm.doc.advances[i].return_amount; amount_to_be_allocated -= advance.allocated_amount; } else { advance.allocated_amount = amount_to_be_allocated; @@ -204,7 +213,6 @@ frappe.ui.form.on("Expense Claim", { frm.refresh_field("advances"); }); }, - make_payment_entry: function (frm) { let method = "hrms.overrides.employee_payment_entry.get_payment_entry_for_employee"; if (frm.doc.__onload && frm.doc.__onload.make_payment_via_journal_entry) { @@ -309,7 +317,12 @@ frappe.ui.form.on("Expense Claim", { row.advance_account = d.advance_account; row.advance_paid = d.paid_amount; row.unclaimed_amount = flt(d.paid_amount) - flt(d.claimed_amount); - row.allocated_amount = 0; + row.return_amount = flt(d.return_amount); + row.allocated_amount = get_allocation_amount( + flt(d.paid_amount), + flt(d.claimed_amount), + flt(d.return_amount), + ); }); refresh_field("advances"); } @@ -385,8 +398,12 @@ frappe.ui.form.on("Expense Claim Advance", { child.advance_paid = r.message[0].paid_amount; child.unclaimed_amount = flt(r.message[0].paid_amount) - flt(r.message[0].claimed_amount); - child.allocated_amount = - flt(r.message[0].paid_amount) - flt(r.message[0].claimed_amount); + child.return_amount = flt(r.message[0].return_amount); + child.allocated_amount = get_allocation_amount( + flt(r.message[0].paid_amount), + flt(r.message[0].claimed_amount), + flt(r.message[0].return_amount), + ); frm.trigger("calculate_grand_total"); refresh_field("advances"); } @@ -432,3 +449,7 @@ frappe.ui.form.on("Expense Taxes and Charges", { frm.trigger("calculate_total_tax", cdt, cdn); }, }); + +function get_allocation_amount(paid_amount, claimed_amount, return_amount) { + return paid_amount - (claimed_amount + return_amount); +} diff --git a/hrms/hr/doctype/expense_claim/expense_claim.py b/hrms/hr/doctype/expense_claim/expense_claim.py index 04a02ba5d1..f3165274a3 100644 --- a/hrms/hr/doctype/expense_claim/expense_claim.py +++ b/hrms/hr/doctype/expense_claim/expense_claim.py @@ -29,6 +29,10 @@ class ExpenseApproverIdentityError(frappe.ValidationError): pass +class MismatchError(frappe.ValidationError): + pass + + class ExpenseClaim(AccountsController, PWANotificationsMixin): def onload(self): self.get("__onload").make_payment_via_journal_entry = frappe.db.get_single_value( @@ -47,6 +51,7 @@ def validate(self): self.set_expense_account(validate=True) self.calculate_taxes() self.set_status() + self.validate_company_and_department() if self.task and not self.project: self.project = frappe.db.get_value("Task", self.task, "project") @@ -83,6 +88,15 @@ def set_status(self, update=False): else: self.status = status + def validate_company_and_department(self): + if self.department: + company = frappe.db.get_value("Department", self.department, "company") + if company and self.company != company: + frappe.throw( + _("Department {0} does not belong to company: {1}").format(self.department, self.company), + exc=MismatchError, + ) + def on_update(self): share_doc_with_approver(self, self.expense_approver) self.publish_update() @@ -325,7 +339,7 @@ def validate_advances(self): ref_doc = frappe.db.get_value( "Employee Advance", d.employee_advance, - ["posting_date", "paid_amount", "claimed_amount", "advance_account"], + ["posting_date", "paid_amount", "claimed_amount", "return_amount", "advance_account"], as_dict=1, ) d.posting_date = ref_doc.posting_date @@ -333,7 +347,9 @@ def validate_advances(self): d.advance_paid = ref_doc.paid_amount d.unclaimed_amount = flt(ref_doc.paid_amount) - flt(ref_doc.claimed_amount) - if d.allocated_amount and flt(d.allocated_amount) > flt(d.unclaimed_amount): + if d.allocated_amount and flt(d.allocated_amount) > ( + flt(d.unclaimed_amount) - flt(d.return_amount) + ): frappe.throw( _("Row {0}# Allocated amount {1} cannot be greater than unclaimed amount {2}").format( d.idx, d.allocated_amount, d.unclaimed_amount @@ -497,6 +513,7 @@ def get_advances(employee, advance_id=None): advance.posting_date, advance.paid_amount, advance.claimed_amount, + advance.return_amount, advance.advance_account, ) @@ -515,7 +532,7 @@ def get_advances(employee, advance_id=None): @frappe.whitelist() def get_expense_claim( - employee_name, company, employee_advance_name, posting_date, paid_amount, claimed_amount + employee_name, company, employee_advance_name, posting_date, paid_amount, claimed_amount, return_amount ): default_payable_account = frappe.get_cached_value( "Company", company, "default_expense_claim_payable_account" @@ -535,7 +552,10 @@ def get_expense_claim( "posting_date": posting_date, "advance_paid": flt(paid_amount), "unclaimed_amount": flt(paid_amount) - flt(claimed_amount), - "allocated_amount": flt(paid_amount) - flt(claimed_amount), + "allocated_amount": get_allocation_amount( + paid_amount=(paid_amount), claimed_amount=(claimed_amount), return_amount=(return_amount) + ), + "return_amount": flt(return_amount), }, ) @@ -592,3 +612,19 @@ def make_expense_claim_for_delivery_trip(source_name, target_doc=None): ) return doc + + +# // amke below fucntion reusable basef on wht is passed, if only unclaimed and return_amt is pased, return unclaimed - returne_amt else paid_amount - (claimed_amount + return_amount) +# @frappe.whitelist() +# def get_allocation_amount(paid_amount, claimed_amount, return_amount): +# return paid_amount - (claimed_amount + return_amount) + + +@frappe.whitelist() +def get_allocation_amount(paid_amount=None, claimed_amount=None, return_amount=None, unclaimed_amount=None): + if unclaimed_amount is not None and return_amount is not None: + return flt(unclaimed_amount) - flt(return_amount) + elif paid_amount is not None and claimed_amount is not None and return_amount is not None: + return flt(paid_amount) - (flt(claimed_amount) + flt(return_amount)) + else: + frappe.throw(_("Invalid parameters provided. Please pass the required arguments.")) diff --git a/hrms/hr/doctype/expense_claim/test_expense_claim.py b/hrms/hr/doctype/expense_claim/test_expense_claim.py index 2d74922eae..52dc555cfc 100644 --- a/hrms/hr/doctype/expense_claim/test_expense_claim.py +++ b/hrms/hr/doctype/expense_claim/test_expense_claim.py @@ -10,6 +10,7 @@ from erpnext.setup.doctype.employee.test_employee import make_employee from hrms.hr.doctype.expense_claim.expense_claim import ( + MismatchError, get_outstanding_amount_for_claim, make_bank_entry, make_expense_claim_for_delivery_trip, @@ -253,6 +254,64 @@ def test_expense_claim_partially_paid_via_advance(self): self.assertEqual(claim.total_amount_reimbursed, 500) self.assertEqual(claim.status, "Paid") + def test_expense_claim_with_deducted_returned_advance(self): + from hrms.hr.doctype.employee_advance.test_employee_advance import ( + create_return_through_additional_salary, + get_advances_for_claim, + make_employee_advance, + make_journal_entry_for_advance, + ) + from hrms.hr.doctype.expense_claim.expense_claim import get_allocation_amount + from hrms.payroll.doctype.salary_component.test_salary_component import create_salary_component + from hrms.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure + + # create employee and employee advance + employee_name = make_employee("_T@employee.advance", "_Test Company") + advance = make_employee_advance(employee_name, {"repay_unclaimed_amount_from_salary": 1}) + journal_entry = make_journal_entry_for_advance(advance) + journal_entry.submit() + advance.reload() + + # set up salary components and structure + create_salary_component("Advance Salary - Deduction", type="Deduction") + make_salary_structure( + "Test Additional Salary for Advance Return", + "Monthly", + employee=employee_name, + company="_Test Company", + ) + + # create additional salary for advance return + additional_salary = create_return_through_additional_salary(advance) + additional_salary.salary_component = "Advance Salary - Deduction" + additional_salary.payroll_date = nowdate() + additional_salary.amount = 400 + additional_salary.insert() + additional_salary.submit() + advance.reload() + + self.assertEqual(advance.return_amount, 400) + + # create an expense claim + payable_account = get_payable_account("_Test Company") + claim = make_expense_claim( + payable_account, 200, 200, "_Test Company", "Travel Expenses - _TC", do_not_submit=True + ) + + # link advance to the claim + claim = get_advances_for_claim(claim, advance.name, amount=200) + claim.save() + claim.submit() + + # verify the allocation amount + advance = claim.advances[0] + self.assertEqual( + get_allocation_amount( + unclaimed_amount=advance.unclaimed_amount, return_amount=advance.return_amount + ), + 600, + ) + def test_expense_claim_gl_entry(self): payable_account = get_payable_account(company_name) taxes = generate_taxes() @@ -568,6 +627,13 @@ def test_repost(self): ) self.assertEqual(ledger_balance, expected_data) + def test_company_department_validation(self): + # validate company and department + expense_claim = frappe.new_doc("Expense Claim") + expense_claim.company = "_Test Company 3" + expense_claim.department = "Accounts - _TC2" + self.assertRaises(MismatchError, expense_claim.save) + def get_payable_account(company): return frappe.get_cached_value("Company", company, "default_payable_account") diff --git a/hrms/hr/doctype/expense_claim_advance/expense_claim_advance.json b/hrms/hr/doctype/expense_claim_advance/expense_claim_advance.json index aa479c8308..5c0b7aadde 100644 --- a/hrms/hr/doctype/expense_claim_advance/expense_claim_advance.json +++ b/hrms/hr/doctype/expense_claim_advance/expense_claim_advance.json @@ -11,6 +11,7 @@ "advance_paid", "column_break_4", "unclaimed_amount", + "return_amount", "allocated_amount", "advance_account" ], @@ -84,11 +85,19 @@ { "fieldname": "column_break_4", "fieldtype": "Column Break" + }, + { + "depends_on": "return_amount", + "fieldname": "return_amount", + "fieldtype": "Currency", + "label": "Returned Amount", + "options": "Company:company:default_currency", + "read_only": 1 } ], "istable": 1, "links": [], - "modified": "2021-11-22 16:33:58.515819", + "modified": "2025-01-29 15:22:37.971097", "modified_by": "Administrator", "module": "HR", "name": "Expense Claim Advance", diff --git a/hrms/hr/doctype/full_and_final_statement/full_and_final_statement.js b/hrms/hr/doctype/full_and_final_statement/full_and_final_statement.js index 74e21f88bc..f638fe9410 100644 --- a/hrms/hr/doctype/full_and_final_statement/full_and_final_statement.js +++ b/hrms/hr/doctype/full_and_final_statement/full_and_final_statement.js @@ -35,6 +35,10 @@ frappe.ui.form.on("Full and Final Statement", { filters["is_group"] = 0; } + if (frappe.model.is_submittable(fnf_doc.reference_document_type)) { + filters["docstatus"] = ["!=", 2]; + } + if (frappe.meta.has_field(fnf_doc.reference_document_type, "company")) { filters["company"] = frm.doc.company; } @@ -130,6 +134,7 @@ frappe.ui.form.on("Full and Final Outstanding Statement", { args: { ref_doctype: child.reference_document_type, ref_document: child.reference_document, + company: frm.doc.company, }, callback: function (r) { if (r.message) { diff --git a/hrms/hr/doctype/full_and_final_statement/full_and_final_statement.py b/hrms/hr/doctype/full_and_final_statement/full_and_final_statement.py index 2a465d1ed6..4d334c6ecd 100644 --- a/hrms/hr/doctype/full_and_final_statement/full_and_final_statement.py +++ b/hrms/hr/doctype/full_and_final_statement/full_and_final_statement.py @@ -6,6 +6,11 @@ from frappe.model.document import Document from frappe.utils import flt, get_link_to_form, today +from hrms.hr.doctype.full_and_final_statement.full_and_final_statement_loan_utils import ( + cancel_loan_repayment, + process_loan_accrual, +) + class FullandFinalStatement(Document): def before_insert(self): @@ -22,8 +27,12 @@ def before_submit(self): self.validate_settlement("receivables") self.validate_assets() + def on_submit(self): + process_loan_accrual(self) + def on_cancel(self): self.ignore_linked_doctypes = ("GL Entry",) + cancel_loan_repayment(self) def validate_relieving_date(self): if not self.relieving_date: @@ -205,7 +214,7 @@ def create_journal_entry(self): "debit_in_account_currency": flt(data.amount, precision), "user_remark": data.remark, } - if data.reference_document_type == "Expense Claim": + if data.reference_document_type in ["Expense Claim", "Gratuity"]: account_dict["party_type"] = "Employee" account_dict["party"] = self.employee @@ -248,9 +257,18 @@ def create_journal_entry(self): ) return jv + def set_gratuity_status(self): + for payable in self.payables: + if payable.component != "Gratuity": + continue + gratuity = frappe.get_doc("Gratuity", payable.reference_document) + amount = payable.amount if self.docstatus == 1 and self.status == "Paid" else 0 + gratuity.db_set("paid_amount", amount) + gratuity.set_status(update=True) + @frappe.whitelist() -def get_account_and_amount(ref_doctype, ref_document): +def get_account_and_amount(ref_doctype, ref_document, company): if not ref_doctype or not ref_document: return None @@ -300,6 +318,11 @@ def get_account_and_amount(ref_doctype, ref_document): amount = details.paid_amount - (details.claimed_amount + details.return_amount) return [payment_account, amount] + if ref_doctype == "Leave Encashment": + amount = frappe.db.get_value("Leave Encashment", ref_document, "encashment_amount") + payable_account = frappe.get_cached_value("Company", company, "default_payroll_payable_account") + return [payable_account, amount] + def update_full_and_final_statement_status(doc, method=None): """Updates FnF status on Journal Entry Submission/Cancellation""" @@ -310,3 +333,4 @@ def update_full_and_final_statement_status(doc, method=None): fnf = frappe.get_doc("Full and Final Statement", entry.reference_name) fnf.db_set("status", status) fnf.notify_update() + fnf.set_gratuity_status() diff --git a/hrms/hr/doctype/full_and_final_statement/full_and_final_statement_loan_utils.py b/hrms/hr/doctype/full_and_final_statement/full_and_final_statement_loan_utils.py new file mode 100644 index 0000000000..530e6ece2b --- /dev/null +++ b/hrms/hr/doctype/full_and_final_statement/full_and_final_statement_loan_utils.py @@ -0,0 +1,101 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from typing import TYPE_CHECKING + +import frappe +from frappe import _ + +from hrms.payroll.doctype.salary_slip.salary_slip_loan_utils import if_lending_app_installed + +if TYPE_CHECKING: + from hrms.payroll.doctype.full_and_final_statement.full_and_final_statement import FullandFinalStatement + + +@if_lending_app_installed +def process_loan_accrual(doc: "FullandFinalStatement"): + from lending.loan_management.doctype.loan_interest_accrual.loan_interest_accrual import ( + make_loan_interest_accrual_entry, + ) + from lending.loan_management.doctype.loan_repayment.loan_repayment import ( + calculate_amounts, + create_repayment_entry, + get_pending_principal_amount, + ) + + loan_receivables = [] + for receivable in doc.receivables: + if receivable.component != "Loan": + continue + + loan_receivables.append(receivable.reference_document) + + for loan in loan_receivables: + loan_doc = frappe.get_doc("Loan", loan) + loan_repayment_schedule = frappe.get_doc("Loan Repayment Schedule", {"loan": loan, "docstatus": 1}) + if loan_repayment_schedule.repayment_schedule: + amounts = [] + for repayment_schedule in loan_repayment_schedule.repayment_schedule: + amounts = calculate_amounts(loan, doc.transaction_date, "Normal Repayment") + pending_principal_amount = get_pending_principal_amount(loan_doc) + if not repayment_schedule.is_accrued: + args = frappe._dict( + { + "loan": loan, + "applicant_type": loan_doc.applicant_type, + "applicant": loan_doc.applicant, + "interest_income_account": loan_doc.interest_income_account, + "loan_account": loan_doc.loan_account, + "pending_principal_amount": amounts["pending_principal_amount"], + "payable_principal": repayment_schedule.principal_amount, + "interest_amount": repayment_schedule.interest_amount, + "total_pending_interest_amount": pending_principal_amount, + "penalty_amount": amounts["penalty_amount"], + "posting_date": doc.transaction_date, + "repayment_schedule_name": repayment_schedule.name, + "accrual_type": "Regular", + "due_date": doc.transaction_date, + } + ) + make_loan_interest_accrual_entry(args) + frappe.db.set_value("Repayment Schedule", repayment_schedule.name, "is_accrued", 1) + + repayment_entry = create_repayment_entry( + loan, + doc.employee, + doc.company, + doc.transaction_date, + loan_doc.loan_product, + "Normal Repayment", + amounts["interest_amount"], + amounts["pending_principal_amount"], + receivable.amount, + ) + + repayment_entry.save() + repayment_entry.submit() + + +@if_lending_app_installed +def cancel_loan_repayment(doc: "FullandFinalStatement"): + loan_receivables = [] + for receivable in doc.receivables: + if receivable.component != "Loan": + continue + + loan_receivables.append(receivable.reference_document) + + for loan in loan_receivables: + posting_date = frappe.utils.getdate(doc.transaction_date) + loan_repayment = frappe.get_doc( + "Loan Repayment", {"against_loan": loan, "docstatus": 1, "posting_date": posting_date} + ) + + if loan_repayment: + loan_repayment.cancel() + + loan_interest_accruals = frappe.get_all( + "Loan Interest Accrual", filters={"loan": loan, "docstatus": 1, "posting_date": posting_date} + ) + for accrual in loan_interest_accruals: + frappe.get_doc("Loan Interest Accrual", accrual.name).cancel() diff --git a/hrms/hr/doctype/full_and_final_statement/test_full_and_final_statement.py b/hrms/hr/doctype/full_and_final_statement/test_full_and_final_statement.py index d1d0deeec3..a10b297253 100644 --- a/hrms/hr/doctype/full_and_final_statement/test_full_and_final_statement.py +++ b/hrms/hr/doctype/full_and_final_statement/test_full_and_final_statement.py @@ -34,7 +34,7 @@ def test_check_bootstraped_data_asset_movement_and_jv_creation(self): "Leave Encashment", ] - receivable_bootstraped_component = ["Employee Advance", "Loan"] + receivable_bootstraped_component = self.fnf.get_receivable_component() # checking payables and receivables bootstraped value self.assertEqual([payable.component for payable in self.fnf.payables], payables_bootstraped_component) diff --git a/hrms/hr/doctype/hr_settings/hr_settings.json b/hrms/hr/doctype/hr_settings/hr_settings.json index 33752dca8b..3a7d960060 100644 --- a/hrms/hr/doctype/hr_settings/hr_settings.json +++ b/hrms/hr/doctype/hr_settings/hr_settings.json @@ -26,6 +26,7 @@ "leave_status_notification_template", "leave_approver_mandatory_in_leave_application", "restrict_backdated_leave_application", + "prevent_self_leave_approval", "role_allowed_to_create_backdated_leave_application", "column_break_29", "expense_approver_mandatory_in_expense_claim", @@ -329,13 +330,19 @@ "fieldname": "unlink_payment_on_cancellation_of_employee_advance", "fieldtype": "Check", "label": " Unlink Payment on Cancellation of Employee Advance" + }, + { + "default": "0", + "fieldname": "prevent_self_leave_approval", + "fieldtype": "Check", + "label": "Prevent self approval for leaves even if user has permissions" } ], "icon": "fa fa-cog", "idx": 1, "issingle": 1, "links": [], - "modified": "2024-09-29 12:49:16.175079", + "modified": "2025-01-30 12:41:22.594071", "modified_by": "Administrator", "module": "HR", "name": "HR Settings", diff --git a/hrms/hr/doctype/job_applicant/job_applicant.json b/hrms/hr/doctype/job_applicant/job_applicant.json index c88f4f538e..2fdd2b9c91 100644 --- a/hrms/hr/doctype/job_applicant/job_applicant.json +++ b/hrms/hr/doctype/job_applicant/job_applicant.json @@ -183,6 +183,7 @@ "fieldtype": "Column Break" }, { + "fetch_from": "job_title.designation", "fetch_if_empty": 1, "fieldname": "designation", "fieldtype": "Link", @@ -194,7 +195,7 @@ "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2023-09-14 17:03:48.838409", + "modified": "2025-01-16 13:06:05.312255", "modified_by": "Administrator", "module": "HR", "name": "Job Applicant", @@ -220,4 +221,4 @@ "states": [], "subject_field": "notes", "title_field": "applicant_name" -} \ No newline at end of file +} diff --git a/hrms/hr/doctype/leave_allocation/leave_allocation.js b/hrms/hr/doctype/leave_allocation/leave_allocation.js index b488ed6d29..d0215619e0 100755 --- a/hrms/hr/doctype/leave_allocation/leave_allocation.js +++ b/hrms/hr/doctype/leave_allocation/leave_allocation.js @@ -72,22 +72,43 @@ frappe.ui.form.on("Leave Allocation", { label: "New Leaves to be Allocated", fieldname: "new_leaves", fieldtype: "Float", + reqd: 1, + }, + { + label: "From Date", + fieldname: "from_date", + fieldtype: "Date", + default: frappe.datetime.get_today(), + }, + { + label: "To Date", + fieldname: "to_date", + fieldtype: "Date", + read_only: 1, + default: frm.doc.to_date, }, ], primary_action_label: "Allocate", - primary_action({ new_leaves }) { + primary_action({ new_leaves, from_date }) { frappe.call({ method: "allocate_leaves_manually", doc: frm.doc, - args: { new_leaves }, - callback: function () { - frm.reload_doc(); + args: { new_leaves, from_date }, + callback: function (r) { + if (!r.exc) { + dialog.hide(); + frm.reload_doc(); + } }, }); - dialog.hide(); }, }); dialog.fields_dict.new_leaves.set_value(monthly_earned_leave); + dialog.fields_dict.from_date.datepicker?.update({ + minDate: frappe.datetime.str_to_obj(frm.doc.from_date), + maxDate: frappe.datetime.str_to_obj(frm.doc.to_date), + }); + dialog.show(); }, __("Actions"), diff --git a/hrms/hr/doctype/leave_allocation/leave_allocation.py b/hrms/hr/doctype/leave_allocation/leave_allocation.py index 61b90939ac..87d4e34e40 100755 --- a/hrms/hr/doctype/leave_allocation/leave_allocation.py +++ b/hrms/hr/doctype/leave_allocation/leave_allocation.py @@ -314,7 +314,15 @@ def create_leave_ledger_entry(self, submit=True): create_leave_ledger_entry(self, args, submit) @frappe.whitelist() - def allocate_leaves_manually(self, new_leaves): + def allocate_leaves_manually(self, new_leaves, from_date=None): + if from_date and not (getdate(self.from_date) <= getdate(from_date) <= getdate(self.to_date)): + frappe.throw( + _("Cannot allocate leaves outside the allocation period {0} - {1}").format( + frappe.bold(formatdate(self.from_date)), frappe.bold(formatdate(self.to_date)) + ), + title=_("Invalid Dates"), + ) + new_allocation = flt(self.total_leaves_allocated) + flt(new_leaves) new_allocation_without_cf = flt( flt(self.get_existing_leave_count()) + flt(new_leaves), @@ -339,13 +347,18 @@ def allocate_leaves_manually(self, new_leaves): ): self.db_set("total_leaves_allocated", new_allocation, update_modified=False) - date = frappe.flags.current_date or getdate() + date = from_date or frappe.flags.current_date or getdate() create_additional_leave_ledger_entry(self, new_leaves, date) text = _("{0} leaves were manually allocated by {1} on {2}").format( frappe.bold(new_leaves), frappe.session.user, frappe.bold(formatdate(date)) ) self.add_comment(comment_type="Info", text=text) + frappe.msgprint( + _("{0} leaves allocated successfully").format(frappe.bold(new_leaves)), + indicator="green", + alert=True, + ) else: msg = _("Total leaves allocated cannot exceed annual allocation of {0}.").format( diff --git a/hrms/hr/doctype/leave_allocation/test_earned_leaves.py b/hrms/hr/doctype/leave_allocation/test_earned_leaves.py index 9f17827f81..b8bf62019d 100644 --- a/hrms/hr/doctype/leave_allocation/test_earned_leaves.py +++ b/hrms/hr/doctype/leave_allocation/test_earned_leaves.py @@ -495,10 +495,23 @@ def test_allocate_leaves_manually(self): get_leave_balance_on(self.employee.name, self.leave_type, frappe.flags.current_date), 6 ) - leave_allocation.allocate_leaves_manually(6) + leave_allocation.allocate_leaves_manually(5) self.assertEqual( - get_leave_balance_on(self.employee.name, self.leave_type, frappe.flags.current_date), 12 + get_leave_balance_on(self.employee.name, self.leave_type, frappe.flags.current_date), 11 ) + + # manually set from_date - applicable from the next day + leave_allocation.allocate_leaves_manually(1, add_days(frappe.flags.current_date, 1)) + # balance should be 11 on the current date + self.assertEqual( + get_leave_balance_on(self.employee.name, self.leave_type, frappe.flags.current_date), 11 + ) + # allocated leave should be applicable from the next day + self.assertEqual( + get_leave_balance_on(self.employee.name, self.leave_type, add_days(frappe.flags.current_date, 1)), + 12, + ) + self.assertRaises(frappe.ValidationError, leave_allocation.allocate_leaves_manually, 1) def tearDown(self): diff --git a/hrms/hr/doctype/leave_application/leave_application.js b/hrms/hr/doctype/leave_application/leave_application.js index 237dc0294b..d2182c87ae 100755 --- a/hrms/hr/doctype/leave_application/leave_application.js +++ b/hrms/hr/doctype/leave_application/leave_application.js @@ -12,7 +12,6 @@ frappe.ui.form.on("Leave Application", { }, }; }); - frm.set_query("employee", erpnext.queries.employee); }, @@ -91,7 +90,6 @@ frappe.ui.form.on("Leave Application", { refresh: function (frm) { hrms.leave_utils.add_view_ledger_button(frm); - if (frm.is_new()) { frm.trigger("calculate_total_days"); } @@ -114,6 +112,7 @@ frappe.ui.form.on("Leave Application", { if (frm.doc.docstatus === 0) { frm.trigger("make_dashboard"); } + frm.trigger("set_form_buttons"); }, async set_employee(frm) { @@ -255,6 +254,29 @@ frappe.ui.form.on("Leave Application", { }); } }, + + set_form_buttons: async function (frm) { + let self_approval_not_allowed = frm.doc.__onload + ? frm.doc.__onload.self_leave_approval_not_allowed + : 0; + let current_employee = await hrms.get_current_employee(); + if ( + frm.doc.docstatus === 0 && + !frm.is_dirty() && + !frappe.model.has_workflow(frm.doctype) + ) { + if (self_approval_not_allowed && current_employee == frm.doc.employee) { + frm.set_df_property("status", "read_only", 1); + frm.trigger("show_save_button"); + } + } + }, + show_save_button: function (frm) { + frm.page.set_primary_action("Save", () => { + frm.save(); + }); + $(".form-message").prop("hidden", true); + }, }); frappe.tour["Leave Application"] = [ diff --git a/hrms/hr/doctype/leave_application/leave_application.py b/hrms/hr/doctype/leave_application/leave_application.py index d8b625d0aa..934918f6ec 100755 --- a/hrms/hr/doctype/leave_application/leave_application.py +++ b/hrms/hr/doctype/leave_application/leave_application.py @@ -5,6 +5,7 @@ import frappe from frappe import _ +from frappe.model.workflow import get_workflow_name from frappe.query_builder.functions import Max, Min, Sum from frappe.utils import ( add_days, @@ -23,6 +24,7 @@ from erpnext.setup.doctype.employee.employee import get_holiday_list_for_employee import hrms +from hrms.api import get_current_employee_info from hrms.hr.doctype.leave_block_list.leave_block_list import get_applicable_block_dates from hrms.hr.doctype.leave_ledger_entry.leave_ledger_entry import create_leave_ledger_entry from hrms.hr.utils import ( @@ -102,6 +104,7 @@ def on_submit(self): self.validate_back_dated_application() self.update_attendance() + self.validate_for_self_approval() # notify leave applier about approval if frappe.db.get_single_value("HR Settings", "send_leave_notification"): @@ -793,6 +796,24 @@ def create_ledger_entry_for_intermediate_allocation_expiry(self, expiry_date, su args.update(dict(from_date=start_date, to_date=self.to_date, leaves=leaves * -1)) create_leave_ledger_entry(self, args, submit) + def validate_for_self_approval(self): + self_leave_approval_not_allowed = frappe.db.get_single_value( + "HR Settings", "prevent_self_leave_approval" + ) + employee_user = frappe.db.get_value("Employee", self.employee, "user_id") + if ( + self_leave_approval_not_allowed + and employee_user == frappe.session.user + and not get_workflow_name("Leave Application") + ): + frappe.throw(_("Self-approval for leaves is not allowed")) + + def onload(self): + self.set_onload( + "self_leave_approval_not_allowed", + frappe.db.get_single_value("HR Settings", "prevent_self_leave_approval"), + ) + def get_allocation_expiry_for_cf_leaves( employee: str, leave_type: str, to_date: datetime.date, from_date: datetime.date diff --git a/hrms/hr/doctype/leave_application/test_leave_application.py b/hrms/hr/doctype/leave_application/test_leave_application.py index 9c0aaa291b..3cc17ae1b0 100644 --- a/hrms/hr/doctype/leave_application/test_leave_application.py +++ b/hrms/hr/doctype/leave_application/test_leave_application.py @@ -967,6 +967,84 @@ def test_leave_approver_perms(self): employee.leave_approver = "" employee.save() + def test_self_leave_approval_allowed(self): + frappe.db.set_single_value("HR Settings", "prevent_self_leave_approval", 0) + + leave_approver = "test_leave_approver@example.com" + make_employee(leave_approver, "_Test Company") + + employee = get_employee() + if not employee.user_id: + employee.user_id = "test_employee@example.com" + employee.leave_approver = leave_approver + employee.save() + + from frappe.utils.user import add_role + + add_role(employee.user_id, "Leave Approver") + + make_allocation_record(employee.name) + application = frappe.get_doc( + doctype="Leave Application", + employee=employee.name, + leave_type="_Test Leave Type", + from_date="2014-06-01", + to_date="2014-06-02", + posting_date="2014-05-30", + description="_Test Reason", + company="_Test Company", + leave_approver=leave_approver, + ) + application.insert() + application.status = "Approved" + + frappe.set_user(employee.user_id) + application.submit() + + self.assertEqual(1, application.docstatus) + + frappe.set_user("Administrator") + + def test_self_leave_approval_not_allowed(self): + frappe.db.set_single_value("HR Settings", "prevent_self_leave_approval", 1) + + leave_approver = "test_leave_approver@example.com" + make_employee(leave_approver, "_Test Company") + + employee = get_employee() + employee.leave_approver = leave_approver + if not employee.user_id: + employee.user_id = "test_employee@example.com" + employee.save() + + from frappe.utils.user import add_role + + add_role(employee.user_id, "Leave Approver") + + make_allocation_record(employee.name) + application = application = frappe.get_doc( + doctype="Leave Application", + employee=employee.name, + leave_type="_Test Leave Type", + from_date="2014-06-03", + to_date="2014-06-04", + posting_date="2014-05-30", + description="_Test Reason", + company="_Test Company", + leave_approver=leave_approver, + ) + application.insert() + application.status = "Approved" + + frappe.set_user(employee.user_id) + self.assertRaises(frappe.ValidationError, application.submit) + + add_role(leave_approver, "Leave Approver") + frappe.set_user(leave_approver) + application.reload() + application.submit() + self.assertEqual(1, application.docstatus) + @set_holiday_list("Salary Slip Test Holiday List", "_Test Company") def test_get_leave_details_for_dashboard(self): employee = get_employee() diff --git a/hrms/hr/doctype/leave_control_panel/leave_control_panel.json b/hrms/hr/doctype/leave_control_panel/leave_control_panel.json index 9e96e61a83..4aeacfc519 100644 --- a/hrms/hr/doctype/leave_control_panel/leave_control_panel.json +++ b/hrms/hr/doctype/leave_control_panel/leave_control_panel.json @@ -186,7 +186,7 @@ "idx": 1, "issingle": 1, "links": [], - "modified": "2024-03-20 15:05:39.635388", + "modified": "2025-01-13 13:47:55.262534", "modified_by": "Administrator", "module": "HR", "name": "Leave Control Panel", @@ -199,8 +199,7 @@ "write": 1 } ], - "read_only": 1, - "sort_field": "modified", + "sort_field": "creation", "sort_order": "DESC", "states": [] } \ No newline at end of file diff --git a/hrms/hr/doctype/leave_encashment/leave_encashment.py b/hrms/hr/doctype/leave_encashment/leave_encashment.py index b32f9ebd67..66481f6bea 100644 --- a/hrms/hr/doctype/leave_encashment/leave_encashment.py +++ b/hrms/hr/doctype/leave_encashment/leave_encashment.py @@ -204,7 +204,9 @@ def create_leave_ledger_entry(self, submit=True): return to_date = leave_allocation.get("to_date") - if to_date < getdate(): + + can_expire = not frappe.db.get_value("Leave Type", self.leave_type, "is_carry_forward") + if to_date < getdate() and can_expire: args = frappe._dict( leaves=self.encashment_days, from_date=to_date, to_date=to_date, is_carry_forward=0 ) diff --git a/hrms/hr/doctype/leave_encashment/test_leave_encashment.py b/hrms/hr/doctype/leave_encashment/test_leave_encashment.py index a315fcf846..d43b2ac50f 100644 --- a/hrms/hr/doctype/leave_encashment/test_leave_encashment.py +++ b/hrms/hr/doctype/leave_encashment/test_leave_encashment.py @@ -8,6 +8,8 @@ from erpnext.setup.doctype.employee.test_employee import make_employee from erpnext.setup.doctype.holiday_list.test_holiday_list import set_holiday_list +from hrms.hr.doctype.leave_allocation.leave_allocation import get_unused_leaves +from hrms.hr.doctype.leave_ledger_entry.leave_ledger_entry import process_expired_allocation from hrms.hr.doctype.leave_period.test_leave_period import create_leave_period from hrms.hr.doctype.leave_policy.test_leave_policy import create_leave_policy from hrms.hr.doctype.leave_policy_assignment.leave_policy_assignment import ( @@ -259,3 +261,107 @@ def test_creation_of_leave_ledger_entry_on_submit(self): frappe.db.delete("Additional Salary", {"ref_docname": leave_encashment.name}) leave_encashment.cancel() self.assertFalse(frappe.db.exists("Leave Ledger Entry", {"transaction_name": leave_encashment.name})) + + @set_holiday_list("_Test Leave Encashment", "_Test Company") + def test_unused_leaves_after_leave_encashment_for_carry_forwarding_leave_type(self): + employee = make_employee("test_employee2_encashment@example.com", company="_Test Company") + # allocated 10 leaves, encashed 5 + leave_encashment = self.get_encashment_created_after_leave_period( + employee, is_carry_forward=1, encashment_days=5 + ) + # check if unused leaves are 5 before processing expired allocation runs + unused_leaves = get_unused_leaves( + employee, self.leave_type, self.leave_period.from_date, self.leave_period.to_date + ) + self.assertEqual(unused_leaves, 5) + + # check if a single leave ledger entry is created + self.assertEqual(frappe.get_value("Leave Type", self.leave_type, "is_carry_forward"), 1) + leave_ledger_entry = frappe.get_all( + "Leave Ledger Entry", fields=["leaves"], filters={"transaction_name": leave_encashment.name} + ) + self.assertEqual(len(leave_ledger_entry), 1) + self.assertEqual(leave_ledger_entry[0].leaves, leave_encashment.encashment_days * -1) + + # check if unused leaves are 5 after processing expired allocation runs + process_expired_allocation() + unused_leaves = get_unused_leaves( + employee, self.leave_type, self.leave_period.from_date, self.leave_period.to_date + ) + self.assertEqual(unused_leaves, 5) + + @set_holiday_list("_Test Leave Encashment", "_Test Company") + def test_leave_expiry_after_leave_encashment_for_non_carry_forwarding_leave_type(self): + employee = make_employee("test_employee3_encashment@example.com", company="_Test Company") + # allocated 10 leaves, encashed 3 + + leave_encashment = self.get_encashment_created_after_leave_period( + employee, is_carry_forward=0, encashment_days=3 + ) + # when leave encashment is created after leave allocation period is over, + # it's assumed that process expired allocation has expired the leaves, + # hence a reverse ledger entry should be created for the encashment + # check if two leave ledger entries are created + self.assertEqual(frappe.get_value("Leave Type", self.leave_type, "is_carry_forward"), 0) + leave_ledger_entry = frappe.get_all( + "Leave Ledger Entry", + fields="*", + filters={"transaction_name": leave_encashment.name}, + order_by="leaves", + ) + self.assertEqual(len(leave_ledger_entry), 2) + self.assertEqual(leave_ledger_entry[0].leaves, leave_encashment.encashment_days * -1) + self.assertEqual(leave_ledger_entry[1].leaves, leave_encashment.encashment_days * 1) + + # check if 10 leaves are expired after processing expired allocation runs + process_expired_allocation() + + expired_leaves = frappe.get_value( + "Leave Ledger Entry", + {"employee": employee, "leave_type": self.leave_type, "is_expired": 1}, + "leaves", + ) + self.assertEqual(expired_leaves, -10) + + def get_encashment_created_after_leave_period(self, employee, is_carry_forward, encashment_days): + frappe.db.delete("Leave Period", {"name": self.leave_period.name}) + # create new leave period that has end date of yesterday + start_date = add_days(getdate(), -30) + end_date = add_days(getdate(), -1) + self.leave_period = create_leave_period(start_date, end_date, "_Test Company") + frappe.db.set_value( + "Leave Type", + self.leave_type, + { + "is_carry_forward": is_carry_forward, + }, + ) + + leave_policy = frappe.get_value("Leave Policy", {"title": "Test Leave Policy"}, "name") + data = { + "assignment_based_on": "Leave Period", + "leave_policy": leave_policy, + "leave_period": self.leave_period.name, + } + create_assignment_for_multiple_employees([employee], frappe._dict(data)) + + make_salary_structure( + "Salary Structure for Encashment", + "Monthly", + employee, + other_details={"leave_encashment_amount_per_day": 50}, + ) + + leave_encashment = frappe.get_doc( + { + "doctype": "Leave Encashment", + "employee": employee, + "leave_type": self.leave_type, + "leave_period": self.leave_period.name, + "encashment_date": self.leave_period.to_date, + "encashment_days": encashment_days, + "currency": "INR", + } + ).insert() + leave_encashment.submit() + return leave_encashment diff --git a/hrms/hr/doctype/leave_ledger_entry/leave_ledger_entry.py b/hrms/hr/doctype/leave_ledger_entry/leave_ledger_entry.py index dd1718aef3..74d467730f 100644 --- a/hrms/hr/doctype/leave_ledger_entry/leave_ledger_entry.py +++ b/hrms/hr/doctype/leave_ledger_entry/leave_ledger_entry.py @@ -4,13 +4,26 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import DATE_FORMAT, flt, get_link_to_form, getdate, today +from frappe.utils import DATE_FORMAT, flt, formatdate, get_link_to_form, getdate, today + + +class InvalidLeaveLedgerEntry(frappe.ValidationError): + pass class LeaveLedgerEntry(Document): def validate(self): if getdate(self.from_date) > getdate(self.to_date): - frappe.throw(_("To date needs to be before from date")) + frappe.throw( + _( + "Leave Ledger Entry's To date needs to be after From date. Currently, From Date is {0} and To Date is {1}" + ).format( + frappe.bold(formatdate(self.from_date)), + frappe.bold(formatdate(self.to_date)), + ), + exc=InvalidLeaveLedgerEntry, + title=_("Invalid Leave Ledger Entry"), + ) def on_cancel(self): # allow cancellation of expiry leaves diff --git a/hrms/hr/doctype/leave_type/leave_type.json b/hrms/hr/doctype/leave_type/leave_type.json index 68ad4da3f1..bc25f221e4 100644 --- a/hrms/hr/doctype/leave_type/leave_type.json +++ b/hrms/hr/doctype/leave_type/leave_type.json @@ -52,12 +52,13 @@ { "fieldname": "max_leaves_allowed", "fieldtype": "Float", - "label": "Maximum Leave Allocation Allowed" + "label": "Maximum Leave Allocation Allowed per Leave Period" }, { + "description": "Minimum working days required since Date of Joining to apply for this leave", "fieldname": "applicable_after", "fieldtype": "Int", - "label": "Applicable After (Working Days)" + "label": "Allow Leave Application After (Working Days)" }, { "fieldname": "max_continuous_days_allowed", @@ -234,7 +235,7 @@ "icon": "fa fa-flag", "idx": 1, "links": [], - "modified": "2023-10-23 15:34:04.281308", + "modified": "2024-12-18 19:51:44.162375", "modified_by": "Administrator", "module": "HR", "name": "Leave Type", diff --git a/hrms/hr/doctype/shift_assignment/shift_assignment.json b/hrms/hr/doctype/shift_assignment/shift_assignment.json index 62f98943b1..6ed201548c 100644 --- a/hrms/hr/doctype/shift_assignment/shift_assignment.json +++ b/hrms/hr/doctype/shift_assignment/shift_assignment.json @@ -21,7 +21,7 @@ "start_date", "end_date", "shift_request", - "schedule", + "shift_schedule_assignment", "amended_from" ], "fields": [ @@ -129,16 +129,16 @@ "fieldtype": "Column Break" }, { - "fieldname": "schedule", + "fieldname": "shift_schedule_assignment", "fieldtype": "Link", - "label": "Schedule", - "options": "Shift Assignment Schedule", + "label": "Shift Schedule Assignment", + "options": "Shift Schedule Assignment", "read_only": 1 } ], "is_submittable": 1, "links": [], - "modified": "2024-07-04 14:30:40.840689", + "modified": "2024-11-13 12:59:50.928962", "modified_by": "Administrator", "module": "HR", "name": "Shift Assignment", diff --git a/hrms/hr/doctype/shift_assignment/shift_assignment_list.js b/hrms/hr/doctype/shift_assignment/shift_assignment_list.js index 897348a624..cb9126a711 100644 --- a/hrms/hr/doctype/shift_assignment/shift_assignment_list.js +++ b/hrms/hr/doctype/shift_assignment/shift_assignment_list.js @@ -1,19 +1,3 @@ frappe.listview_settings["Shift Assignment"] = { - onload: function (list_view) { - list_view.page.add_inner_button( - __("Shift Assignment Tool"), - function () { - frappe.set_route("Form", "Shift Assignment Tool"); - }, - __("View"), - ); - - list_view.page.add_inner_button( - __("Roster"), - function () { - window.location.href = "/hr/roster"; - }, - __("View"), - ); - }, + onload: (list_view) => hrms.add_shift_tools_button_to_list(list_view), }; diff --git a/hrms/hr/doctype/shift_assignment_tool/shift_assignment_tool.js b/hrms/hr/doctype/shift_assignment_tool/shift_assignment_tool.js index d034ef3d61..000294af8f 100644 --- a/hrms/hr/doctype/shift_assignment_tool/shift_assignment_tool.js +++ b/hrms/hr/doctype/shift_assignment_tool/shift_assignment_tool.js @@ -17,6 +17,11 @@ frappe.ui.form.on("Shift Assignment Tool", { "completed_bulk_shift_assignment", "Shift Assignment", ); + hrms.handle_realtime_bulk_action_notification( + frm, + "completed_bulk_shift_schedule_assignment", + "Shift Schedule Assignment", + ); hrms.handle_realtime_bulk_action_notification( frm, "completed_bulk_shift_request_processing", @@ -55,6 +60,10 @@ frappe.ui.form.on("Shift Assignment Tool", { frm.trigger("get_employees"); }, + shift_schedule(frm) { + frm.trigger("get_employees"); + }, + approver(frm) { frm.trigger("get_employees"); }, @@ -93,38 +102,45 @@ frappe.ui.form.on("Shift Assignment Tool", { const select_rows_section_head = document .querySelector('[data-fieldname="select_rows_section"]') .querySelector(".section-head"); + select_rows_section_head.textContent = __("Select Employees"); + frm.clear_custom_buttons(); + frm.page.clear_primary_action(); - if (frm.doc.action === "Assign Shift") { - frm.clear_custom_buttons(); + if (frm.doc.action === "Assign Shift") frm.page.set_primary_action(__("Assign Shift"), () => { - frm.trigger("assign_shift"); + frm.trigger("bulk_assign"); + }); + else if (frm.doc.action === "Assign Shift Schedule") + frm.page.set_primary_action(__("Assign Shift Schedule"), () => { + frm.trigger("bulk_assign"); }); - select_rows_section_head.textContent = __("Select Employees"); - return; + else { + frm.page.add_inner_button( + __("Approve"), + () => { + frm.events.process_shift_requests(frm, "Approved"); + }, + __("Process Requests"), + ); + frm.page.add_inner_button( + __("Reject"), + () => { + frm.events.process_shift_requests(frm, "Rejected"); + }, + __("Process Requests"), + ); + frm.page.set_inner_btn_group_as_primary(__("Process Requests")); + frm.page.clear_menu(); + select_rows_section_head.textContent = __("Select Shift Requests"); } - - frm.page.clear_primary_action(); - frm.page.add_inner_button( - __("Approve"), - () => { - frm.events.process_shift_requests(frm, "Approved"); - }, - __("Process Requests"), - ); - frm.page.add_inner_button( - __("Reject"), - () => { - frm.events.process_shift_requests(frm, "Rejected"); - }, - __("Process Requests"), - ); - frm.page.set_inner_btn_group_as_primary(__("Process Requests")); - frm.page.clear_menu(); - select_rows_section_head.textContent = __("Select Shift Requests"); }, get_employees(frm) { - if (frm.doc.action === "Assign Shift" && !(frm.doc.shift_type && frm.doc.start_date)) + if ( + (frm.doc.action === "Assign Shift" && !(frm.doc.shift_type && frm.doc.start_date)) || + (frm.doc.action === "Assign Shift Schedule" && + !(frm.doc.shift_schedule && frm.doc.start_date)) + ) return frm.events.render_employees_datatable(frm, []); frm.call({ @@ -146,6 +162,13 @@ frappe.ui.form.on("Shift Assignment Tool", { ? "There are no employees without Shift Assignments for these dates based on the given filters." : "Please select Shift Type and assignment date(s).", ); + } else if (frm.doc.action === "Assign Shift Schedule") { + columns = frm.events.get_assign_shift_datatable_columns(); + no_data_message = __( + frm.doc.shift_schedule && frm.doc.start_date + ? "There are no employees without active overlapping Shift Schedule Assignments based on the given filters." + : "Please select Shift Schedule and assignment date(s).", + ); } else { columns = frm.events.get_process_shift_requests_datatable_columns(); no_data_message = "There are no open Shift Requests based on the given filters."; @@ -224,7 +247,8 @@ frappe.ui.form.on("Shift Assignment Tool", { align: "left", })); }, - assign_shift(frm) { + + bulk_assign(frm, employees) { const rows = frm.employees_datatable.datamanager.data; const selected_employees = []; const checked_row_indexes = frm.employees_datatable.rowmanager.getCheckedRows(); @@ -233,21 +257,20 @@ frappe.ui.form.on("Shift Assignment Tool", { }); hrms.validate_mandatory_fields(frm, selected_employees); - frappe.confirm(__("Assign Shift to {0} employee(s)?", [selected_employees.length]), () => { - frm.events.bulk_assign_shift(frm, selected_employees); - }); - }, - - bulk_assign_shift(frm, employees) { - frm.call({ - method: "bulk_assign_shift", - doc: frm.doc, - args: { - employees: employees, + frappe.confirm( + __("{0} to {1} employee(s)?", [__(frm.doc.action), selected_employees.length]), + () => { + frm.call({ + method: "bulk_assign", + doc: frm.doc, + args: { + employees: selected_employees, + }, + freeze: true, + freeze_message: __("Assigning..."), + }); }, - freeze: true, - freeze_message: __("Assigning Shift"), - }); + ); }, process_shift_requests(frm, status) { diff --git a/hrms/hr/doctype/shift_assignment_tool/shift_assignment_tool.json b/hrms/hr/doctype/shift_assignment_tool/shift_assignment_tool.json index 64801f6bb3..f6fc00be05 100644 --- a/hrms/hr/doctype/shift_assignment_tool/shift_assignment_tool.json +++ b/hrms/hr/doctype/shift_assignment_tool/shift_assignment_tool.json @@ -11,6 +11,8 @@ "company", "shift_assignment_details_section", "shift_type", + "shift_schedule", + "shift_location", "status", "column_break_ybmd", "start_date", @@ -40,7 +42,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Action", - "options": "Assign Shift\nProcess Shift Requests", + "options": "Assign Shift\nAssign Shift Schedule\nProcess Shift Requests", "reqd": 1 }, { @@ -56,6 +58,7 @@ "reqd": 1 }, { + "depends_on": "eval:doc.action === \"Assign Shift\"", "fieldname": "shift_type", "fieldtype": "Link", "in_list_view": 1, @@ -80,7 +83,7 @@ "fieldtype": "Date", "in_list_view": 1, "label": "Start Date", - "mandatory_depends_on": "eval:doc.action === \"Assign Shift\"" + "mandatory_depends_on": "eval:doc.action === \"Assign Shift\" || doc.action === \"Assign Shift Schedule\"" }, { "fieldname": "end_date", @@ -172,7 +175,7 @@ "label": "Shift Request Filters" }, { - "depends_on": "eval:doc.action === \"Assign Shift\"", + "depends_on": "eval:doc.action === \"Assign Shift\" || doc.action === \"Assign Shift Schedule\"", "fieldname": "shift_assignment_details_section", "fieldtype": "Section Break", "label": "Shift Assignment Details" @@ -187,12 +190,26 @@ "fieldname": "select_rows_section", "fieldtype": "Section Break", "label": "Select Employees" + }, + { + "fieldname": "shift_location", + "fieldtype": "Link", + "label": "Shift Location", + "options": "Shift Location" + }, + { + "depends_on": "eval:doc.action === \"Assign Shift Schedule\"", + "fieldname": "shift_schedule", + "fieldtype": "Link", + "label": "Shift Schedule", + "mandatory_depends_on": "eval:doc.action === \"Assign Shift Schedule\"", + "options": "Shift Schedule" } ], "hide_toolbar": 1, "issingle": 1, "links": [], - "modified": "2024-03-27 17:54:33.829320", + "modified": "2025-01-13 13:48:33.710186", "modified_by": "Administrator", "module": "HR", "name": "Shift Assignment Tool", @@ -208,7 +225,6 @@ "write": 1 } ], - "read_only": 1, "sort_field": "modified", "sort_order": "DESC", "states": [] diff --git a/hrms/hr/doctype/shift_assignment_tool/shift_assignment_tool.py b/hrms/hr/doctype/shift_assignment_tool/shift_assignment_tool.py index fb33da5094..19bbf608d1 100644 --- a/hrms/hr/doctype/shift_assignment_tool/shift_assignment_tool.py +++ b/hrms/hr/doctype/shift_assignment_tool/shift_assignment_tool.py @@ -30,9 +30,9 @@ def get_employees(self, advanced_filters: list | None = None) -> list: filters = [[d, "=", self.get(d)] for d in quick_filter_fields if self.get(d)] filters += advanced_filters - if self.action == "Assign Shift": - return self.get_employees_for_assigning_shift(filters) - return self.get_shift_requests(filters) + if self.action == "Process Shift Requests": + return self.get_shift_requests(filters) + return self.get_employees_for_assigning_shift(filters) def get_employees_for_assigning_shift(self, filters): Employee = frappe.qb.DocType("Employee") @@ -55,8 +55,17 @@ def get_employees_for_assigning_shift(self, filters): query = query.where( (Employee.relieving_date >= self.end_date) | (Employee.relieving_date.isnull()) ) - if self.status == "Active": + + self.allow_multiple_shifts = frappe.db.get_single_value( + "HR Settings", "allow_multiple_shift_assignments" + ) + if self.action == "Assign Shift Schedule": + query = query.where( + Employee.employee.notin(SubQuery(self.get_query_for_employees_with_same_shift_schedule())) + ) + elif self.status == "Active": query = query.where(Employee.employee.notin(SubQuery(self.get_query_for_employees_with_shifts()))) + return query.run(as_dict=True) def get_shift_requests(self, filters): @@ -97,16 +106,9 @@ def get_shift_requests(self, filters): def get_query_for_employees_with_shifts(self): ShiftAssignment = frappe.qb.DocType("Shift Assignment") - query = frappe.qb.from_(ShiftAssignment) - - allow_multiple_shifts = frappe.db.get_single_value("HR Settings", "allow_multiple_shift_assignments") - # join Shift Type if multiple shifts are allowed as we need to know shift timings only in this case - if allow_multiple_shifts: - ShiftType = frappe.qb.DocType("Shift Type") - query = query.left_join(ShiftType).on(ShiftAssignment.shift_type == ShiftType.name) - query = ( - query.select(ShiftAssignment.employee) + frappe.qb.from_(ShiftAssignment) + .select(ShiftAssignment.employee) .distinct() .where( (ShiftAssignment.status == "Active") @@ -115,68 +117,131 @@ def get_query_for_employees_with_shifts(self): & ((ShiftAssignment.end_date >= self.start_date) | (ShiftAssignment.end_date.isnull())) ) ) + if self.end_date: query = query.where(ShiftAssignment.start_date <= self.end_date) - # check for overlapping timings if multiple shifts are allowed - if allow_multiple_shifts: - shift_start, shift_end = frappe.db.get_value( - "Shift Type", self.shift_type, ["start_time", "end_time"] - ) - # turn it into a 48 hour clock for easier conditioning while considering overnight shifts - if shift_end < shift_start: - shift_end += timedelta(hours=24) - end_time_case = ( - Case() - .when(ShiftType.end_time < ShiftType.start_time, ShiftType.end_time + Interval(hours=24)) - .else_(ShiftType.end_time) - ) - query = query.where((end_time_case >= shift_start) & (ShiftType.start_time <= shift_end)) + if self.allow_multiple_shifts: + query = self.get_query_checking_overlapping_shift_timings(query, ShiftAssignment, self.shift_type) + + return query + + def get_query_for_employees_with_same_shift_schedule(self): + days = frappe.get_all("Assignment Rule Day", {"parent": self.shift_schedule}, pluck="day") + + ShiftScheduleAssignment = frappe.qb.DocType("Shift Schedule Assignment") + ShiftSchedule = frappe.qb.DocType("Shift Schedule") + Day = frappe.qb.DocType("Assignment Rule Day") + + query = ( + frappe.qb.from_(ShiftScheduleAssignment) + .left_join(ShiftSchedule) + .on(ShiftSchedule.name == ShiftScheduleAssignment.shift_schedule) + .left_join(Day) + .on(ShiftSchedule.name == Day.parent) + .select(ShiftScheduleAssignment.employee) + .distinct() + .where((ShiftScheduleAssignment.enabled == 1) & (Day.day.isin(days))) + ) + + if self.allow_multiple_shifts: + shift_type = frappe.db.get_value("Shift Schedule", self.shift_schedule, "shift_type") + query = self.get_query_checking_overlapping_shift_timings(query, ShiftSchedule, shift_type) return query + def get_query_checking_overlapping_shift_timings(self, query, doctype, shift_type): + shift_start, shift_end = frappe.db.get_value("Shift Type", shift_type, ["start_time", "end_time"]) + # turn it into a 48 hour clock for easier conditioning while considering overnight shifts + if shift_end < shift_start: + shift_end += timedelta(hours=24) + + ShiftType = frappe.qb.DocType("Shift Type") + end_time_case = ( + Case() + .when(ShiftType.end_time < ShiftType.start_time, ShiftType.end_time + Interval(hours=24)) + .else_(ShiftType.end_time) + ) + + return ( + query.left_join(ShiftType) + .on(doctype.shift_type == ShiftType.name) + .where((end_time_case >= shift_start) & (ShiftType.start_time <= shift_end)) + ) + @frappe.whitelist() - def bulk_assign_shift(self, employees: list): - mandatory_fields = ["company", "shift_type", "start_date"] + def bulk_assign(self, employees: list): + if self.action == "Assign Shift": + mandatory_fields = ["shift_type"] + doctype = "Shift Assignments" + + elif self.action == "Assign Shift Schedule": + mandatory_fields = ["shift_schedule"] + doctype = "Shift Schedule Assignments" + + else: + frappe.throw(_("Invalid Action")) + + mandatory_fields.extend(["company", "start_date"]) + validate_bulk_tool_fields(self, mandatory_fields, employees, "start_date", "end_date") - if len(employees) <= 30: - return self._bulk_assign_shift(employees) + if self.action == "Assign Shift" and len(employees) <= 30: + return self._bulk_assign(employees) - frappe.enqueue(self._bulk_assign_shift, timeout=3000, employees=employees) + frappe.enqueue(self._bulk_assign, timeout=3000, employees=employees) frappe.msgprint( - _("Creation of Shift Assignments has been queued. It may take a few minutes."), + _("Creation of {0} has been queued. It may take a few minutes.").format(doctype), alert=True, indicator="blue", ) - def _bulk_assign_shift(self, employees: list): + def _bulk_assign(self, employees: list): success, failure = [], [] count = 0 - savepoint = "before_shift_assignment" + savepoint = "before_assignment" + if self.action == "Assign Shift": + doctype = "Shift Assignment" + event = "completed_bulk_shift_assignment" + else: + doctype = "Shift Schedule Assignment" + event = "completed_bulk_shift_schedule_assignment" for d in employees: try: frappe.db.savepoint(savepoint) - assignment = create_shift_assignment( - d, self.company, self.shift_type, self.start_date, self.end_date, self.status + assignment = ( + self.create_shift_schedule_assignment(d) + if self.action == "Assign Shift Schedule" + else create_shift_assignment( + d, + self.company, + self.shift_type, + self.start_date, + self.end_date, + self.status, + self.shift_location, + ) ) + if self.action == "Assign Shift Schedule": + assignment.create_shifts(self.start_date, self.end_date) + except Exception: frappe.db.rollback(save_point=savepoint) frappe.log_error( - f"Bulk Assignment - Shift Assignment failed for employee {d}.", - reference_doctype="Shift Assignment", + f"Bulk Assignment - {doctype} failed for employee {d}.", + reference_doctype=doctype, ) failure.append(d) else: - success.append({"doc": get_link_to_form("Shift Assignment", assignment), "employee": d}) + success.append({"doc": get_link_to_form(doctype, assignment.name), "employee": d}) count += 1 - frappe.publish_progress(count * 100 / len(employees), title=_("Assigning Shift...")) + frappe.publish_progress(count * 100 / len(employees), title=_("Creating {0}...").format(doctype)) frappe.clear_messages() frappe.publish_realtime( - "completed_bulk_shift_assignment", + event, message={"success": success, "failure": failure}, doctype="Shift Assignment Tool", after_commit=True, @@ -235,6 +300,18 @@ def _bulk_process_shift_requests(self, shift_requests: list, status: str): after_commit=True, ) + def create_shift_schedule_assignment(self, employee: str) -> str: + assignment = frappe.new_doc("Shift Schedule Assignment") + assignment.shift_schedule = self.shift_schedule + assignment.employee = employee + assignment.company = self.company + assignment.shift_status = self.status + assignment.shift_location = self.shift_location + assignment.enabled = 0 if self.end_date else 1 + assignment.create_shifts_after = self.start_date + assignment.save() + return assignment + def create_shift_assignment( employee: str, @@ -243,7 +320,8 @@ def create_shift_assignment( start_date: str, end_date: str, status: str, - schedule: str | None = None, + shift_location: str | None = None, + shift_schedule_assignment: str | None = None, ) -> str: assignment = frappe.new_doc("Shift Assignment") assignment.employee = employee @@ -252,7 +330,8 @@ def create_shift_assignment( assignment.start_date = start_date assignment.end_date = end_date assignment.status = status - assignment.schedule = schedule + assignment.shift_location = shift_location + assignment.shift_schedule_assignment = shift_schedule_assignment assignment.save() assignment.submit() - return assignment.name + return assignment diff --git a/hrms/hr/doctype/shift_assignment_tool/test_shift_assignment_tool.py b/hrms/hr/doctype/shift_assignment_tool/test_shift_assignment_tool.py index 1f064c6e4a..c428843bef 100644 --- a/hrms/hr/doctype/shift_assignment_tool/test_shift_assignment_tool.py +++ b/hrms/hr/doctype/shift_assignment_tool/test_shift_assignment_tool.py @@ -2,13 +2,14 @@ # See license.txt import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, getdate from erpnext.setup.doctype.employee.test_employee import make_employee from hrms.hr.doctype.shift_assignment_tool.shift_assignment_tool import ShiftAssignmentTool from hrms.hr.doctype.shift_request.test_shift_request import make_shift_request +from hrms.hr.doctype.shift_schedule.shift_schedule import get_or_insert_shift_schedule from hrms.hr.doctype.shift_type.test_shift_type import make_shift_assignment, setup_shift_type from hrms.tests.test_utils import create_company @@ -20,6 +21,10 @@ def setUp(self): self.shift1 = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00") self.shift2 = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="15:00:00") self.shift3 = setup_shift_type(shift_type="Shift 3", start_time="14:00:00", end_time="18:00:00") + self.schedule1 = get_or_insert_shift_schedule(self.shift1.name, "Every Week", ["Monday"]) + self.schedule2 = get_or_insert_shift_schedule(self.shift2.name, "Every Week", ["Monday"]) + self.schedule3 = get_or_insert_shift_schedule(self.shift3.name, "Every Week", ["Monday"]) + self.schedule4 = get_or_insert_shift_schedule(self.shift1.name, "Every Week", ["Tuesday"]) self.emp1 = make_employee("employee1@test.com", company="_Test Company") self.emp2 = make_employee("employee2@test.com", company="_Test Company") self.emp3 = make_employee("employee3@test.com", company="_Test Company") @@ -29,6 +34,7 @@ def setUp(self): def tearDown(self): frappe.db.rollback() + @change_settings("HR Settings", {"allow_multiple_shift_assignments": 0}) def test_get_employees_for_assigning_shifts(self): today = getdate() @@ -72,6 +78,38 @@ def test_get_employees_for_assigning_shifts(self): self.assertIn(self.emp1, employee_names) self.assertIn(self.emp2, employee_names) + def test_get_employees_for_assigning_shift_schedule(self): + today = getdate() + + args = { + "doctype": "Shift Assignment Tool", + "action": "Assign Shift Schedule", + "company": "_Test Company", # excludes emp4 + "shift_schedule": self.schedule1, + "start_date": today, + } + shift_assignment_tool = ShiftAssignmentTool(args) + advanced_filters = [["employee_name", "like", "%test.com%"]] # excludes emp5 + + # does not exclude emp1 as days don't overlap + make_shift_schedule_assignment(self.schedule4, self.emp1) + # excludes emp2 due to overlapping days + make_shift_schedule_assignment(self.schedule2, self.emp2) + # excludes emp3 due to overlapping days + make_shift_schedule_assignment(self.schedule3, self.emp3) + + employees = shift_assignment_tool.get_employees(advanced_filters) + self.assertEqual(len(employees), 1) # emp1 + + # includes emp3 as multiple shifts in a day are allowed and timings don't overlap + frappe.db.set_single_value("HR Settings", "allow_multiple_shift_assignments", 1) + employees = shift_assignment_tool.get_employees(advanced_filters) + self.assertEqual(len(employees), 2) # emp1, emp3 + + employee_names = [d.employee for d in employees] + self.assertIn(self.emp1, employee_names) + self.assertIn(self.emp3, employee_names) + def test_get_shift_requests(self): today = getdate() @@ -162,7 +200,7 @@ def test_bulk_assign_shift(self): shift_assignment_tool = ShiftAssignmentTool(args) employees = [self.emp1, self.emp2, self.emp3] - shift_assignment_tool.bulk_assign_shift(employees) + shift_assignment_tool.bulk_assign(employees) shift_assignment_employees = frappe.get_list( "Shift Assignment", filters={ @@ -178,6 +216,35 @@ def test_bulk_assign_shift(self): self.assertIn(self.emp2, shift_assignment_employees) self.assertIn(self.emp3, shift_assignment_employees) + def test_bulk_assign_shift_schedule(self): + today = getdate() + + args = { + "doctype": "Shift Assignment Tool", + "action": "Assign Shift Schedule", + "company": "_Test Company", + "shift_schedule": self.schedule1, + "status": "Active", + "start_date": today, + "end_date": add_days(today, 10), + } + shift_assignment_tool = ShiftAssignmentTool(args) + + employees = [self.emp1, self.emp2, self.emp3] + shift_assignment_tool._bulk_assign(employees) + assigned_employees = frappe.get_list( + "Shift Schedule Assignment", + filters={ + "shift_schedule": self.schedule1, + "shift_status": "Active", + "enabled": 0, + }, + pluck="employee", + ) + self.assertIn(self.emp1, assigned_employees) + self.assertIn(self.emp2, assigned_employees) + self.assertIn(self.emp3, assigned_employees) + def test_bulk_process_shift_requests(self): for emp in [self.emp1, self.emp2, self.emp3]: employee = frappe.get_doc("Employee", emp) @@ -231,3 +298,15 @@ def test_bulk_process_shift_requests(self): shift_assignment = frappe.db.exists("Shift Assignment", {"shift_request": request3.name}) self.assertTrue(shift_assignment) + + +def make_shift_schedule_assignment(schedule, employee, create_shifts_after=None, enabled=1): + assignment = frappe.new_doc("Shift Schedule Assignment") + assignment.shift_schedule = schedule + assignment.employee = employee + assignment.company = "_Test Company" + assignment.enabled = enabled + assignment.create_shifts_after = create_shifts_after or getdate() + assignment.save() + + return assignment.name diff --git a/hrms/hr/doctype/shift_location/shift_location.js b/hrms/hr/doctype/shift_location/shift_location.js index 3439f8d097..dff91f02f8 100644 --- a/hrms/hr/doctype/shift_location/shift_location.js +++ b/hrms/hr/doctype/shift_location/shift_location.js @@ -2,7 +2,7 @@ // For license information, please see license.txt frappe.ui.form.on("Shift Location", { - refresh: async () => { + refresh: async (frm) => { const allow_geolocation_tracking = await frappe.db.get_single_value( "HR Settings", "allow_geolocation_tracking", @@ -16,6 +16,12 @@ frappe.ui.form.on("Shift Location", { "longitude", "geolocation", ]); + + if (!frm.doc.__islocal) + hrms.add_shift_tools_button_to_form(frm, { + action: "Assign Shift", + shift_location: frm.doc.name, + }); }, fetch_geolocation: (frm) => { diff --git a/hrms/hr/doctype/shift_location/shift_location_list.js b/hrms/hr/doctype/shift_location/shift_location_list.js new file mode 100644 index 0000000000..291ee4997b --- /dev/null +++ b/hrms/hr/doctype/shift_location/shift_location_list.js @@ -0,0 +1,3 @@ +frappe.listview_settings["Shift Location"] = { + onload: (list_view) => hrms.add_shift_tools_button_to_list(list_view), +}; diff --git a/hrms/hr/doctype/shift_request/shift_request_list.js b/hrms/hr/doctype/shift_request/shift_request_list.js index 30c4bdb7be..b456a2c4a4 100644 --- a/hrms/hr/doctype/shift_request/shift_request_list.js +++ b/hrms/hr/doctype/shift_request/shift_request_list.js @@ -1,9 +1,4 @@ frappe.listview_settings["Shift Request"] = { - onload: function (list_view) { - list_view.page.add_inner_button(__("Shift Assignment Tool"), function () { - const doc = frappe.model.get_new_doc("Shift Assignment Tool"); - doc.action = "Process Shift Requests"; - frappe.set_route("Form", "Shift Assignment Tool", doc.name); - }); - }, + onload: (list_view) => + hrms.add_shift_tools_button_to_list(list_view, "Process Shift Requests"), }; diff --git a/hrms/hr/doctype/shift_assignment_schedule/__init__.py b/hrms/hr/doctype/shift_schedule/__init__.py similarity index 100% rename from hrms/hr/doctype/shift_assignment_schedule/__init__.py rename to hrms/hr/doctype/shift_schedule/__init__.py diff --git a/hrms/hr/doctype/shift_schedule/shift_schedule.js b/hrms/hr/doctype/shift_schedule/shift_schedule.js new file mode 100644 index 0000000000..df5c6e7d0e --- /dev/null +++ b/hrms/hr/doctype/shift_schedule/shift_schedule.js @@ -0,0 +1,12 @@ +// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Shift Schedule", { + refresh(frm) { + if (frm.doc.docstatus === 1) + hrms.add_shift_tools_button_to_form(frm, { + action: "Assign Shift Schedule", + shift_schedule: frm.doc.name, + }); + }, +}); diff --git a/hrms/hr/doctype/shift_schedule/shift_schedule.json b/hrms/hr/doctype/shift_schedule/shift_schedule.json new file mode 100644 index 0000000000..eacf1cd2a4 --- /dev/null +++ b/hrms/hr/doctype/shift_schedule/shift_schedule.json @@ -0,0 +1,114 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "prompt", + "creation": "2024-11-11 16:56:33.536882", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "schedule_settings_section", + "shift_type", + "column_break_iprq", + "frequency", + "repeat_on_days", + "amended_from" + ], + "fields": [ + { + "fieldname": "schedule_settings_section", + "fieldtype": "Section Break" + }, + { + "fieldname": "frequency", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Frequency", + "options": "Every Week\nEvery 2 Weeks\nEvery 3 Weeks\nEvery 4 Weeks", + "reqd": 1 + }, + { + "fieldname": "repeat_on_days", + "fieldtype": "Table", + "label": "Repeat On Days", + "options": "Assignment Rule Day", + "reqd": 1 + }, + { + "fieldname": "column_break_iprq", + "fieldtype": "Column Break" + }, + { + "fieldname": "shift_type", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Shift Type", + "options": "Shift Type", + "reqd": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Shift Schedule", + "print_hide": 1, + "read_only": 1, + "search_index": 1 + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [ + { + "link_doctype": "Shift Schedule Assignment", + "link_fieldname": "shift_schedule" + } + ], + "modified": "2024-12-19 13:34:43.731635", + "modified_by": "Administrator", + "module": "HR", + "name": "Shift Schedule", + "naming_rule": "Set by user", + "owner": "Administrator", + "permissions": [ + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Employee", + "share": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR User", + "share": 1, + "write": 1 + }, + { + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR Manager", + "share": 1, + "submit": 1, + "write": 1 + } + ], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/hrms/hr/doctype/shift_schedule/shift_schedule.py b/hrms/hr/doctype/shift_schedule/shift_schedule.py new file mode 100644 index 0000000000..d286c23f76 --- /dev/null +++ b/hrms/hr/doctype/shift_schedule/shift_schedule.py @@ -0,0 +1,47 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document +from frappe.utils import random_string + + +class ShiftSchedule(Document): + def before_validate(self): + to_be_deleted = [] + seen_days = set() + + for d in self.repeat_on_days: + if d.day in seen_days: + to_be_deleted.append(d) + else: + seen_days.add(d.day) + + for d in to_be_deleted: + self.remove(d) + + +def get_or_insert_shift_schedule(shift_type: str, frequency: str, repeat_on_days: list[str]) -> str: + shift_schedules = frappe.get_all( + "Shift Schedule", + pluck="name", + filters={"shift_type": shift_type, "frequency": frequency, "docstatus": 1}, + ) + + for shift_schedule in shift_schedules: + shift_schedule = frappe.get_doc("Shift Schedule", shift_schedule) + shift_schedule_days = [d.day for d in shift_schedule.repeat_on_days] + if sorted(repeat_on_days) == sorted(shift_schedule_days): + return shift_schedule.name + + doc = frappe.get_doc( + { + "doctype": "Shift Schedule", + "name": random_string(10), + "shift_type": shift_type, + "frequency": frequency, + "repeat_on_days": [{"day": day} for day in repeat_on_days], + } + ).insert() + doc.submit() + return doc.name diff --git a/hrms/hr/doctype/shift_schedule/shift_schedule_list.js b/hrms/hr/doctype/shift_schedule/shift_schedule_list.js new file mode 100644 index 0000000000..2b9044e24a --- /dev/null +++ b/hrms/hr/doctype/shift_schedule/shift_schedule_list.js @@ -0,0 +1,3 @@ +frappe.listview_settings["Shift Schedule"] = { + onload: (list_view) => hrms.add_shift_tools_button_to_list(list_view, "Assign Shift Schedule"), +}; diff --git a/hrms/hr/doctype/shift_schedule/test_shift_schedule.py b/hrms/hr/doctype/shift_schedule/test_shift_schedule.py new file mode 100644 index 0000000000..df1d037426 --- /dev/null +++ b/hrms/hr/doctype/shift_schedule/test_shift_schedule.py @@ -0,0 +1,20 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + +# On FrappeTestCase, the doctype test records and all +# link-field test record depdendencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class TestShiftSchedule(FrappeTestCase): + """ + Integration tests for ShiftSchedule. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/hrms/hr/doctype/shift_schedule_assignment/__init__.py b/hrms/hr/doctype/shift_schedule_assignment/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hrms/hr/doctype/shift_assignment_schedule/shift_assignment_schedule.js b/hrms/hr/doctype/shift_schedule_assignment/shift_schedule_assignment.js similarity index 75% rename from hrms/hr/doctype/shift_assignment_schedule/shift_assignment_schedule.js rename to hrms/hr/doctype/shift_schedule_assignment/shift_schedule_assignment.js index 892b5eae44..bbc5b25f21 100644 --- a/hrms/hr/doctype/shift_assignment_schedule/shift_assignment_schedule.js +++ b/hrms/hr/doctype/shift_schedule_assignment/shift_schedule_assignment.js @@ -1,7 +1,7 @@ // Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt -// frappe.ui.form.on("Shift Assignment Schedule", { +// frappe.ui.form.on("Shift Schedule Assignment", { // refresh(frm) { // }, diff --git a/hrms/hr/doctype/shift_assignment_schedule/shift_assignment_schedule.json b/hrms/hr/doctype/shift_schedule_assignment/shift_schedule_assignment.json similarity index 77% rename from hrms/hr/doctype/shift_assignment_schedule/shift_assignment_schedule.json rename to hrms/hr/doctype/shift_schedule_assignment/shift_schedule_assignment.json index e60f62bce1..18d2b6a480 100644 --- a/hrms/hr/doctype/shift_assignment_schedule/shift_assignment_schedule.json +++ b/hrms/hr/doctype/shift_schedule_assignment/shift_schedule_assignment.json @@ -1,76 +1,63 @@ { "actions": [], - "autoname": "HR-SAS-.YY.-.MM.-.#####", - "creation": "2024-05-28 15:19:50.016396", + "autoname": "HR-SHSA-.YY.-.MM.-.#####", + "creation": "2024-11-11 17:33:00.330488", "doctype": "DocType", "engine": "InnoDB", "field_order": [ - "schedule_settings_section", - "frequency", - "repeat_on_days", - "column_break_iprq", - "enabled", - "create_shifts_after", "shift_details_section", "employee", "employee_name", - "shift_type", "column_break_toss", "company", - "shift_status" + "schedule_settings_section", + "shift_schedule", + "shift_location", + "shift_status", + "column_break_iprq", + "enabled", + "create_shifts_after" ], "fields": [ + { + "fieldname": "schedule_settings_section", + "fieldtype": "Section Break", + "label": "Shift Details" + }, { "fieldname": "column_break_iprq", "fieldtype": "Column Break" }, { - "fieldname": "frequency", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Frequency", - "options": "Every Week\nEvery 2 Weeks\nEvery 3 Weeks\nEvery 4 Weeks", - "reqd": 1 + "default": "1", + "description": "Select this if you want shift assignments to be automatically created indefinitely.", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "default": "Today", + "depends_on": "eval:doc.enabled", + "description": "New shift assignments will be created after this date.", + "fieldname": "create_shifts_after", + "fieldtype": "Date", + "label": "Create Shifts After", + "mandatory_depends_on": "eval:doc.status === 'Active'" }, { "fieldname": "shift_details_section", "fieldtype": "Section Break", - "label": "Shift Details" + "label": "Employee Details" }, { "fieldname": "employee", "fieldtype": "Link", + "in_list_view": 1, "in_standard_filter": 1, "label": "Employee", "options": "Employee", "reqd": 1 }, - { - "fetch_from": "employee.company", - "fieldname": "company", - "fieldtype": "Link", - "label": "Company", - "options": "Company", - "read_only": 1, - "reqd": 1 - }, - { - "fieldname": "column_break_toss", - "fieldtype": "Column Break" - }, - { - "fieldname": "shift_type", - "fieldtype": "Link", - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Shift Type", - "options": "Shift Type", - "reqd": 1 - }, - { - "fieldname": "schedule_settings_section", - "fieldtype": "Section Break" - }, { "fetch_from": "employee.employee_name", "fieldname": "employee_name", @@ -79,47 +66,51 @@ "read_only": 1 }, { - "default": "Today", - "depends_on": "eval:doc.enabled", - "description": "New shift assignments will be created after this date.", - "fieldname": "create_shifts_after", - "fieldtype": "Date", - "label": "Create Shifts After", - "mandatory_depends_on": "eval:doc.status === 'Active'" + "fieldname": "column_break_toss", + "fieldtype": "Column Break" }, { - "fieldname": "repeat_on_days", - "fieldtype": "Table", - "label": "Repeat On Days", - "options": "Assignment Rule Day", + "fetch_from": "employee.company", + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "read_only": 1, "reqd": 1 }, - { - "default": "1", - "description": "Select this if you want shift assignments to be automatically created indefinitely.", - "fieldname": "enabled", - "fieldtype": "Check", - "label": "Enabled" - }, { "default": "Active", "fieldname": "shift_status", "fieldtype": "Select", - "label": "Status", + "label": "Shift Status", "options": "Active\nInactive" + }, + { + "fieldname": "shift_schedule", + "fieldtype": "Link", + "label": "Shift Schedule", + "options": "Shift Schedule", + "reqd": 1 + }, + { + "fieldname": "shift_location", + "fieldtype": "Link", + "label": "Shift Location", + "options": "Shift Location" } ], "index_web_pages_for_search": 1, "links": [ { "link_doctype": "Shift Assignment", - "link_fieldname": "schedule" + "link_fieldname": "shift_schedule_assignment" } ], - "modified": "2024-06-27 14:37:31.797684", + "modified": "2024-12-10 15:44:00.063685", "modified_by": "Administrator", "module": "HR", - "name": "Shift Assignment Schedule", + "name": "Shift Schedule Assignment", "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ diff --git a/hrms/hr/doctype/shift_assignment_schedule/shift_assignment_schedule.py b/hrms/hr/doctype/shift_schedule_assignment/shift_schedule_assignment.py similarity index 52% rename from hrms/hr/doctype/shift_assignment_schedule/shift_assignment_schedule.py rename to hrms/hr/doctype/shift_schedule_assignment/shift_schedule_assignment.py index 84c31ea3d6..24a405fe3f 100644 --- a/hrms/hr/doctype/shift_assignment_schedule/shift_assignment_schedule.py +++ b/hrms/hr/doctype/shift_schedule_assignment/shift_schedule_assignment.py @@ -3,62 +3,76 @@ import frappe from frappe.model.document import Document -from frappe.utils import add_days, get_weekday, nowdate +from frappe.utils import add_days, get_weekday, getdate, nowdate from hrms.hr.doctype.shift_assignment_tool.shift_assignment_tool import create_shift_assignment -class ShiftAssignmentSchedule(Document): +class ShiftScheduleAssignment(Document): def create_shifts(self, start_date: str, end_date: str | None = None) -> None: + shift_schedule = frappe.get_doc("Shift Schedule", self.shift_schedule) gap = { "Every Week": 0, "Every 2 Weeks": 1, "Every 3 Weeks": 2, "Every 4 Weeks": 3, - }[self.frequency] + }[shift_schedule.frequency] date = start_date individual_assignment_start = None - week_end_day = get_weekday(add_days(start_date, -1)) - repeat_on_days = [day.day for day in self.repeat_on_days] + week_end_day = get_weekday(getdate(add_days(start_date, -1))) + repeat_on_days = [day.day for day in shift_schedule.repeat_on_days] if not end_date: end_date = add_days(start_date, 90) while date <= end_date: - weekday = get_weekday(date) + weekday = get_weekday(getdate(date)) if weekday in repeat_on_days: if not individual_assignment_start: individual_assignment_start = date if date == end_date: - self.create_individual_assignment(individual_assignment_start, date) + self.create_individual_assignment( + shift_schedule.shift_type, individual_assignment_start, date + ) elif individual_assignment_start: - self.create_individual_assignment(individual_assignment_start, add_days(date, -1)) + self.create_individual_assignment( + shift_schedule.shift_type, individual_assignment_start, add_days(date, -1) + ) individual_assignment_start = None if weekday == week_end_day and gap: if individual_assignment_start: - self.create_individual_assignment(individual_assignment_start, date) + self.create_individual_assignment( + shift_schedule.shift_type, individual_assignment_start, date + ) individual_assignment_start = None date = add_days(date, 7 * gap) date = add_days(date, 1) - def create_individual_assignment(self, start_date, end_date): + def create_individual_assignment(self, shift_type, start_date, end_date): create_shift_assignment( - self.employee, self.company, self.shift_type, start_date, end_date, self.shift_status, self.name + self.employee, + self.company, + shift_type, + start_date, + end_date, + self.shift_status, + self.shift_location, + self.name, ) self.create_shifts_after = end_date self.save() def process_auto_shift_creation(): - schedules = frappe.get_all( - "Shift Assignment Schedule", + shift_schedule_assignments = frappe.get_all( + "Shift Schedule Assignment", filters={"enabled": 1, "create_shifts_after": ["<=", nowdate()]}, pluck="name", ) - for d in schedules: - doc = frappe.get_doc("Shift Assignment Schedule", d) + for d in shift_schedule_assignments: + doc = frappe.get_doc("Shift Schedule Assignment", d) doc.create_shifts(add_days(doc.create_shifts_after, 1)) diff --git a/hrms/hr/doctype/shift_schedule_assignment/shift_schedule_assignment_list.js b/hrms/hr/doctype/shift_schedule_assignment/shift_schedule_assignment_list.js new file mode 100644 index 0000000000..ecfd2d6d37 --- /dev/null +++ b/hrms/hr/doctype/shift_schedule_assignment/shift_schedule_assignment_list.js @@ -0,0 +1,3 @@ +frappe.listview_settings["Shift Schedule Assignment"] = { + onload: (list_view) => hrms.add_shift_tools_button_to_list(list_view, "Assign Shift Schedule"), +}; diff --git a/hrms/hr/doctype/shift_schedule_assignment/test_shift_schedule_assignment.py b/hrms/hr/doctype/shift_schedule_assignment/test_shift_schedule_assignment.py new file mode 100644 index 0000000000..660aae0192 --- /dev/null +++ b/hrms/hr/doctype/shift_schedule_assignment/test_shift_schedule_assignment.py @@ -0,0 +1,20 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + +# On FrappeTestCase, the doctype test records and all +# link-field test record depdendencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class TestShiftScheduleAssignment(FrappeTestCase): + """ + Integration tests for ShiftScheduleAssignment. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/hrms/hr/doctype/shift_type/shift_type.js b/hrms/hr/doctype/shift_type/shift_type.js index 85a2efd2aa..47d99e26cf 100644 --- a/hrms/hr/doctype/shift_type/shift_type.js +++ b/hrms/hr/doctype/shift_type/shift_type.js @@ -5,51 +5,35 @@ frappe.ui.form.on("Shift Type", { refresh: function (frm) { if (frm.doc.__islocal) return; - frm.add_custom_button( - __("Bulk Assign Shift"), - () => { - const doc = frappe.model.get_new_doc("Shift Assignment Tool"); - doc.action = "Assign Shift"; - doc.company = frappe.defaults.get_default("company"); - doc.shift_type = frm.doc.name; - doc.status = "Active"; - frappe.set_route("Form", "Shift Assignment Tool", doc.name); - }, - __("Actions"), - ); + hrms.add_shift_tools_button_to_form(frm, { + action: "Assign Shift", + shift_type: frm.doc.name, + }); - frm.add_custom_button( - __("Mark Attendance"), - () => { - if (!frm.doc.enable_auto_attendance) { - frm.scroll_to_field("enable_auto_attendance"); - frappe.throw( - __("Please Enable Auto Attendance and complete the setup first."), - ); - } + frm.add_custom_button(__("Mark Attendance"), () => { + if (!frm.doc.enable_auto_attendance) { + frm.scroll_to_field("enable_auto_attendance"); + frappe.throw(__("Please Enable Auto Attendance and complete the setup first.")); + } - if (!frm.doc.process_attendance_after) { - frm.scroll_to_field("process_attendance_after"); - frappe.throw(__("Please set {0}.", [__("Process Attendance After").bold()])); - } + if (!frm.doc.process_attendance_after) { + frm.scroll_to_field("process_attendance_after"); + frappe.throw(__("Please set {0}.", [__("Process Attendance After").bold()])); + } - if (!frm.doc.last_sync_of_checkin) { - frm.scroll_to_field("last_sync_of_checkin"); - frappe.throw(__("Please set {0}.", [__("Last Sync of Checkin").bold()])); - } + if (!frm.doc.last_sync_of_checkin) { + frm.scroll_to_field("last_sync_of_checkin"); + frappe.throw(__("Please set {0}.", [__("Last Sync of Checkin").bold()])); + } - frm.call({ - doc: frm.doc, - method: "process_auto_attendance", - freeze: true, - callback: () => { - frappe.msgprint( - __("Attendance has been marked as per employee check-ins"), - ); - }, - }); - }, - __("Actions"), - ); + frm.call({ + doc: frm.doc, + method: "process_auto_attendance", + freeze: true, + callback: () => { + frappe.msgprint(__("Attendance has been marked as per employee check-ins")); + }, + }); + }); }, }); diff --git a/hrms/hr/doctype/shift_type/shift_type.json b/hrms/hr/doctype/shift_type/shift_type.json index c0e63d9bfd..088a810b6c 100644 --- a/hrms/hr/doctype/shift_type/shift_type.json +++ b/hrms/hr/doctype/shift_type/shift_type.json @@ -3,6 +3,7 @@ "autoname": "prompt", "creation": "2018-04-13 16:22:52.954783", "doctype": "DocType", + "documentation": "https://docs.frappe.io/hr/shift-type", "editable_grid": 1, "engine": "InnoDB", "field_order": [ @@ -137,10 +138,12 @@ "label": "Enable Auto Attendance" }, { + "default": "Today", "description": "Attendance will be marked automatically only after this date.", "fieldname": "process_attendance_after", "fieldtype": "Date", - "label": "Process Attendance After" + "label": "Process Attendance After", + "mandatory_depends_on": "enable_auto_attendance" }, { "description": "Last Known Successful Sync of Employee Checkin. Reset this only if you are sure that all Logs are synced from all the locations. Please don't modify this if you are unsure.", @@ -176,7 +179,7 @@ } ], "links": [], - "modified": "2024-05-17 15:48:27.191003", + "modified": "2024-12-18 19:03:38.278336", "modified_by": "Administrator", "module": "HR", "name": "Shift Type", diff --git a/hrms/hr/doctype/shift_type/shift_type.py b/hrms/hr/doctype/shift_type/shift_type.py index 328706c3f2..6dc8cefb56 100644 --- a/hrms/hr/doctype/shift_type/shift_type.py +++ b/hrms/hr/doctype/shift_type/shift_type.py @@ -70,7 +70,6 @@ def process_auto_attendance(self): frappe.db.commit() # nosemgrep assigned_employees = self.get_assigned_employees(self.process_attendance_after, True) - # mark absent in batches & commit to avoid losing progress since this tries to process remaining attendance # right from "Process Attendance After" to "Last Sync of Checkin" for batch in create_batch(assigned_employees, EMPLOYEE_CHUNK_SIZE): @@ -234,15 +233,22 @@ def get_marked_attendance_dates_between(self, employee: str, start_date: str, en ) ).run(pluck=True) - def get_assigned_employees(self, from_date=None, consider_default_shift=False) -> list[str]: + def get_assigned_employees(self, from_date: datetime.date, consider_default_shift=False) -> list[str]: + """Get all such employees who either have this shift assigned that hasn't ended or have this shift as default shift. + This may fetch some redundant employees who have another shift assigned that may have started or ended before or after the + attendance processing date. But this is done to avoid missing any employee who may have this shift as active shift.""" filters = {"shift_type": self.name, "docstatus": "1", "status": "Active"} - if from_date: - filters["start_date"] = (">=", from_date) - assigned_employees = frappe.get_all("Shift Assignment", filters=filters, pluck="employee") + or_filters = [["end_date", ">=", from_date], ["end_date", "is", "not set"]] + + assigned_employees = frappe.get_all( + "Shift Assignment", filters=filters, or_filters=or_filters, pluck="employee" + ) if consider_default_shift: - default_shift_employees = self.get_employees_with_default_shift(filters) + default_shift_employees = frappe.get_all( + "Employee", filters={"default_shift": self.name, "status": "Active"}, pluck="name" + ) assigned_employees = set(assigned_employees + default_shift_employees) # exclude inactive employees @@ -250,26 +256,6 @@ def get_assigned_employees(self, from_date=None, consider_default_shift=False) - return list(set(assigned_employees) - set(inactive_employees)) - def get_employees_with_default_shift(self, filters: dict) -> list: - default_shift_employees = frappe.get_all( - "Employee", filters={"default_shift": self.name, "status": "Active"}, pluck="name" - ) - - if not default_shift_employees: - return [] - - # exclude employees from default shift list if any other valid shift assignment exists - del filters["shift_type"] - filters["employee"] = ("in", default_shift_employees) - - active_shift_assignments = frappe.get_all( - "Shift Assignment", - filters=filters, - pluck="employee", - ) - - return list(set(default_shift_employees) - set(active_shift_assignments)) - def get_holiday_list(self, employee: str) -> str: holiday_list_name = self.holiday_list or get_holiday_list_for_employee(employee, False) return holiday_list_name diff --git a/hrms/hr/doctype/shift_type/shift_type_list.js b/hrms/hr/doctype/shift_type/shift_type_list.js index 1d5773c4fe..8581089167 100644 --- a/hrms/hr/doctype/shift_type/shift_type_list.js +++ b/hrms/hr/doctype/shift_type/shift_type_list.js @@ -1,7 +1,3 @@ frappe.listview_settings["Shift Type"] = { - onload: function (list_view) { - list_view.page.add_inner_button(__("Shift Assignment Tool"), function () { - frappe.set_route("Form", "Shift Assignment Tool"); - }); - }, + onload: (list_view) => hrms.add_shift_tools_button_to_list(list_view), }; diff --git a/hrms/hr/doctype/shift_type/test_shift_type.py b/hrms/hr/doctype/shift_type/test_shift_type.py index 997856a52d..49d5b4c4b8 100644 --- a/hrms/hr/doctype/shift_type/test_shift_type.py +++ b/hrms/hr/doctype/shift_type/test_shift_type.py @@ -347,28 +347,83 @@ def test_mark_absent_for_dates_with_no_attendance_for_midnight_shift(self): shift_type="Test Absent with no Attendance", start_time="15:00:00", end_time="23:30:00", - process_attendance_after=add_days(today, -6), + process_attendance_after=add_days(today, -8), allow_check_out_after_shift_end_time=120, last_sync_of_checkin=f"{today} 15:00:00", ) # single day assignment - date1 = add_days(today, -5) + date1 = add_days(today, -7) make_shift_assignment(shift_type.name, employee, date1, date1) - # assignment without end date - date2 = add_days(today, -4) + # assignment after a gap + date2 = add_days(today, -5) make_shift_assignment(shift_type.name, employee, date2, date2) + # assignment without end date + date3 = add_days(today, -3) + make_shift_assignment(shift_type.name, employee, date3) + shift_type.process_auto_attendance() absent_records = frappe.get_all( "Attendance", - { + fields=["name", "employee", "attendance_date", "status", "shift"], + filters={ "attendance_date": ["between", [date1, today]], "employee": employee, "status": "Absent", }, ) - self.assertEqual(len(absent_records), 2) + + self.assertEqual(len(absent_records), 5) + # absent for first assignment + self.assertEqual( + frappe.db.get_value( + "Attendance", + {"attendance_date": date1, "shift": shift_type.name, "employee": employee}, + "status", + ), + "Absent", + ) + # no attendance for day after first assignment + self.assertIsNone( + frappe.db.get_value( + "Attendance", + {"attendance_date": add_days(date1, 1), "shift": shift_type.name, "employee": employee}, + ) + ) + # absent for second assignment + self.assertEqual( + frappe.db.get_value( + "Attendance", + {"attendance_date": date2, "shift": shift_type.name, "employee": employee}, + "status", + ), + "Absent", + ) + # no attendance for day after second assignment + self.assertIsNone( + frappe.db.get_value( + "Attendance", + {"attendance_date": add_days(date2, 1), "shift": shift_type.name, "employee": employee}, + ) + ) + # absent for third assignment + self.assertEqual( + frappe.db.get_value( + "Attendance", + {"attendance_date": date3, "shift": shift_type.name, "employee": employee}, + "status", + ), + "Absent", + ) + self.assertEqual( + frappe.db.get_value( + "Attendance", + {"attendance_date": add_days(date3, 1), "shift": shift_type.name, "employee": employee}, + "status", + ), + "Absent", + ) def test_do_not_mark_absent_before_shift_actual_end_time(self): from hrms.hr.doctype.employee_checkin.test_employee_checkin import make_checkin @@ -612,6 +667,27 @@ def test_skip_auto_attendance_for_overlapping_shift(self): self.assertEqual(log_in.skip_auto_attendance, 1) self.assertEqual(log_out.skip_auto_attendance, 1) + def test_mark_attendance_for_default_shift_when_shift_assignment_is_not_overlapping(self): + shift_1 = setup_shift_type(shift_type="Deafult Shift", start_time="08:00:00", end_time="12:00:00") + shift_2 = setup_shift_type(shift_type="Not Default Shift", start_time="10:00:00", end_time="18:00:00") + employee = make_employee( + "test_employee_attendance@example.com", company="_Test Company", default_shift=shift_1.name + ) + shift_assigned_date = add_days(getdate(), +1) + make_shift_assignment(shift_2.name, employee, shift_assigned_date) + from hrms.hr.doctype.attendance.attendance import mark_attendance + + mark_attendance(employee, add_days(getdate(), -1), "Present", shift=shift_1.name) + shift_1.process_auto_attendance() + self.assertEqual( + frappe.db.get_value( + "Attendance", + {"employee": employee, "attendance_date": getdate(), "shift": shift_1.name}, + "status", + ), + "Absent", + ) + def setup_shift_type(**args): args = frappe._dict(args) diff --git a/hrms/hr/report/employee_leave_balance/employee_leave_balance.py b/hrms/hr/report/employee_leave_balance/employee_leave_balance.py index 6c308b1d6a..d8289bd07e 100644 --- a/hrms/hr/report/employee_leave_balance/employee_leave_balance.py +++ b/hrms/hr/report/employee_leave_balance/employee_leave_balance.py @@ -207,8 +207,7 @@ def get_allocated_and_expired_leaves( carry_forwarded_leaves += record.leaves else: new_allocation += record.leaves - # carry forwarded leaves also get counted in expired, hence subtracting them - expired_leaves -= carry_forwarded_leaves + return new_allocation, expired_leaves, carry_forwarded_leaves diff --git a/hrms/hr/report/employee_leave_balance/test_employee_leave_balance.py b/hrms/hr/report/employee_leave_balance/test_employee_leave_balance.py index 5a8737a985..548742f308 100644 --- a/hrms/hr/report/employee_leave_balance/test_employee_leave_balance.py +++ b/hrms/hr/report/employee_leave_balance/test_employee_leave_balance.py @@ -241,47 +241,3 @@ def test_employee_status_filter(self): ) report = execute(filters) self.assertEqual(len(report[1]), 1) - - @set_holiday_list("_Test Emp Balance Holiday List", "_Test Company") - def test_closing_balance_considers_carry_forwarded_leaves(self): - leave_type = create_leave_type(leave_type_name="_Test_CF_leave_expiry", is_carry_forward=1) - # 30 leaves allocated for first half of the year - allocation1 = make_allocation_record( - employee=self.employee_id, - from_date=self.year_start, - to_date=self.mid_year, - leave_type=leave_type.name, - ) - # 4 days leave application in the first allocation - first_sunday = get_first_sunday(self.holiday_list, for_date=self.year_start) - leave_application = make_leave_application( - self.employee_id, first_sunday, add_days(first_sunday, 3), leave_type.name - ) - leave_application.reload() - # expires 26 leaves - process_expired_allocation() - # carry forward 26 expired leaves + allocate 4 new leaves - allocation2 = make_allocation_record( - employee=self.employee_id, - from_date=add_days(self.mid_year, 1), - to_date=self.year_end, - leaves=4, - carry_forward=True, - leave_type=leave_type.name, - ) - - filters = frappe._dict( - { - "from_date": self.year_start, - "to_date": self.year_end, - "employee": self.employee_id, - } - ) - report = execute(filters) - - closing_balance = ( - allocation1.new_leaves_allocated - - leave_application.total_leave_days - + allocation2.new_leaves_allocated - ) - self.assertEqual(report[1][0].closing_balance, closing_balance) diff --git a/hrms/hr/report/leave_ledger/test_leave_ledger.py b/hrms/hr/report/leave_ledger/test_leave_ledger.py index 160f42699c..ce758de163 100644 --- a/hrms/hr/report/leave_ledger/test_leave_ledger.py +++ b/hrms/hr/report/leave_ledger/test_leave_ledger.py @@ -33,21 +33,26 @@ def setUp(self): self.year_start = getdate(get_year_start(self.date)) self.year_end = getdate(get_year_ending(self.date)) - self.holiday_list = make_holiday_list( - "_Test Emp Balance Holiday List", self.year_start, self.year_end + holiday_list = make_holiday_list( + "_Test Emp Balance Holiday List", + self.year_start, + self.year_end, + add_weekly_offs=False, ) - - # create employee 1 & 2 self.employee_1 = frappe.get_doc( - "Employee", make_employee("test_emp_1@example.com", company="_Test Company") + "Employee", + make_employee("test_emp_1@example.com", company="_Test Company", holiday_list=holiday_list), ) self.employee_2 = frappe.get_doc( - "Employee", make_employee("test_emp_2@example.com", company="_Test Company") + "Employee", + make_employee("test_emp_2@example.com", company="_Test Company", holiday_list=holiday_list), ) # create leave type self.earned_leave = "Test Earned Leave" self.casual_leave = "_Test Leave Type" + create_leave_type(leave_type=self.earned_leave) + create_leave_type(leave_type=self.casual_leave) self.create_earned_leave_allocation() self.create_casual_leave_allocation() diff --git a/hrms/hr/utils.py b/hrms/hr/utils.py index ba45c2387e..c3fae61e33 100644 --- a/hrms/hr/utils.py +++ b/hrms/hr/utils.py @@ -542,17 +542,21 @@ def get_salary_assignments(employee, payroll_period): order_by="from_date", ) - if not assignments: + if not assignments or getdate(assignments[0].from_date) > getdate(start_date): # if no assignments found for the given period - # get the last one assigned before the period that is still active - assignments = frappe.get_all( + # or the latest assignment hast started in the middle of the period + # get the last one assigned before the period start date + past_assignment = frappe.get_all( "Salary Structure Assignment", - filters={"employee": employee, "docstatus": 1, "from_date": ["<=", start_date]}, + filters={"employee": employee, "docstatus": 1, "from_date": ["<", start_date]}, fields=["*"], order_by="from_date desc", limit=1, ) + if past_assignment: + assignments = past_assignment + assignments + return assignments @@ -783,9 +787,9 @@ def get_ec_matching_query( ref_rank = frappe.qb.terms.Case().when(ec.employee == common_filters.party, 1).else_(0) + 1 if exact_match: - filters.append(ec.total_sanctioned_amount == common_filters.amount) + filters.append(ec.total_amount_reimbursed == common_filters.amount) else: - filters.append(ec.total_sanctioned_amount.gt(common_filters.amount)) + filters.append(ec.total_amount_reimbursed.gt(common_filters.amount)) else: ref_rank = ConstantColumn(1) @@ -796,6 +800,7 @@ def get_ec_matching_query( qb.from_(ec) .select( ref_rank.as_("rank"), + ConstantColumn("Expense Claim").as_("doctype"), ec.name, ec.total_sanctioned_amount.as_("paid_amount"), ConstantColumn("").as_("reference_no"), diff --git a/hrms/hr/workspace/shift_&_attendance/shift_&_attendance.json b/hrms/hr/workspace/shift_&_attendance/shift_&_attendance.json index 9e0595bde8..9abf0b7454 100644 --- a/hrms/hr/workspace/shift_&_attendance/shift_&_attendance.json +++ b/hrms/hr/workspace/shift_&_attendance/shift_&_attendance.json @@ -193,9 +193,19 @@ { "hidden": 0, "is_query_report": 0, - "label": "Shift Assignment Schedule", + "label": "Shift Schedule", "link_count": 0, - "link_to": "Shift Assignment Schedule", + "link_to": "Shift Schedule", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Shift Schedule Assignment", + "link_count": 0, + "link_to": "Shift Schedule Assignment", "link_type": "DocType", "onboard": 0, "type": "Link" @@ -221,7 +231,7 @@ "type": "Link" } ], - "modified": "2024-08-07 14:42:16.893494", + "modified": "2024-12-10 17:51:08.275060", "modified_by": "Administrator", "module": "HR", "name": "Shift & Attendance", @@ -273,4 +283,4 @@ } ], "title": "Shift & Attendance" -} \ No newline at end of file +} diff --git a/hrms/overrides/company.py b/hrms/overrides/company.py index c9fe4d6397..2636d51f71 100644 --- a/hrms/overrides/company.py +++ b/hrms/overrides/company.py @@ -132,3 +132,55 @@ def validate_default_accounts(doc, method=None): "The currency of {0} should be same as the company's default currency. Please select another account." ).format(frappe.bold(_("Default Payroll Payable Account"))) ) + + +def handle_linked_docs(doc, method=None): + delete_docs_with_company_field(doc) + clear_company_field_for_single_doctypes(doc) + + +def delete_docs_with_company_field(doc, method=None): + """ + Deletes records from linked doctypes where the 'company' field matches the company's name + """ + company_data_to_be_ignored = frappe.get_hooks("company_data_to_be_ignored") or [] + for doctype in company_data_to_be_ignored: + records_to_delete = frappe.get_all(doctype, filters={"company": doc.name}, pluck="name") + if records_to_delete: + frappe.db.delete(doctype, {"name": ["in", records_to_delete]}) + + +def clear_company_field_for_single_doctypes(doc): + """ + Clears the 'company' value in Single doctypes where applicable + """ + single_docs = get_single_doctypes_with_company_field() + singles = frappe.qb.DocType("Singles") + ( + frappe.qb.update(singles) + .set(singles.value, "") + .where(singles.doctype.isin(single_docs)) + .where(singles.field == "company") + .where(singles.value == doc.name) + ).run() + + +def get_single_doctypes_with_company_field(): + DocType = frappe.qb.DocType("DocType") + DocField = frappe.qb.DocType("DocField") + + return ( + frappe.qb.from_(DocField) + .select(DocField.parent) + .where( + (DocField.fieldtype == "Link") + & (DocField.options == "Company") + & ( + DocField.parent.isin( + frappe.qb.from_(DocType) + .select(DocType.name) + .where((DocType.issingle == 1) & (DocType.module.isin(["HR", "Payroll"]))) + ) + ) + ) + ).run(pluck=True) diff --git a/hrms/patches.txt b/hrms/patches.txt index 8ca18becf8..9f88a81e0f 100644 --- a/hrms/patches.txt +++ b/hrms/patches.txt @@ -24,3 +24,4 @@ hrms.patches.v15_0.make_hr_settings_tab_in_company_master hrms.patches.v15_0.enable_allow_checkin_setting hrms.patches.v15_0.set_default_asset_action_in_fnf hrms.patches.v15_0.add_loan_docperms_to_ess #2024-05-14 +hrms.patches.v15_0.migrate_shift_assignment_schedule_to_shift_schedule diff --git a/hrms/patches/v15_0/migrate_shift_assignment_schedule_to_shift_schedule.py b/hrms/patches/v15_0/migrate_shift_assignment_schedule_to_shift_schedule.py new file mode 100644 index 0000000000..94b09c8997 --- /dev/null +++ b/hrms/patches/v15_0/migrate_shift_assignment_schedule_to_shift_schedule.py @@ -0,0 +1,29 @@ +import frappe + +from hrms.hr.doctype.shift_schedule.shift_schedule import get_or_insert_shift_schedule + + +def execute(): + if not frappe.db.has_table("Shift Assignment Schedule"): + return + + fields = ["name", "shift_type", "frequency", "employee", "shift_status", "enabled", "create_shifts_after"] + for doc in frappe.get_all("Shift Assignment Schedule", fields=fields): + repeat_on_days = frappe.get_all( + "Assignment Rule Day", {"parent": doc.name}, pluck="day", distinct=True + ) + shift_schedule_name = get_or_insert_shift_schedule(doc.shift_type, doc.frequency, repeat_on_days) + + schedule_assignment = frappe.get_doc( + { + "doctype": "Shift Schedule Assignment", + "shift_schedule": shift_schedule_name, + "employee": doc.employee, + "shift_status": doc.shift_status, + "enabled": doc.enabled, + "create_shifts_after": doc.create_shifts_after, + } + ).insert() + + for d in frappe.get_all("Shift Assignment", filters={"schedule": doc.name}, pluck="name"): + frappe.db.set_value("Shift Assignment", d, "shift_schedule_assignment", schedule_assignment.name) diff --git a/hrms/payroll/doctype/bulk_salary_structure_assignment/bulk_salary_structure_assignment.json b/hrms/payroll/doctype/bulk_salary_structure_assignment/bulk_salary_structure_assignment.json index d9eb073199..a0abf3760e 100644 --- a/hrms/payroll/doctype/bulk_salary_structure_assignment/bulk_salary_structure_assignment.json +++ b/hrms/payroll/doctype/bulk_salary_structure_assignment/bulk_salary_structure_assignment.json @@ -147,7 +147,7 @@ "hide_toolbar": 1, "issingle": 1, "links": [], - "modified": "2024-07-09 19:33:40.135057", + "modified": "2025-01-13 13:48:46.095481", "modified_by": "Administrator", "module": "Payroll", "name": "Bulk Salary Structure Assignment", @@ -163,8 +163,7 @@ "write": 1 } ], - "read_only": 1, - "sort_field": "modified", + "sort_field": "creation", "sort_order": "DESC", "states": [] } \ No newline at end of file diff --git a/hrms/payroll/doctype/employee_tax_exemption_declaration/test_employee_tax_exemption_declaration.py b/hrms/payroll/doctype/employee_tax_exemption_declaration/test_employee_tax_exemption_declaration.py index ac6175ae4d..e0e7036b73 100644 --- a/hrms/payroll/doctype/employee_tax_exemption_declaration/test_employee_tax_exemption_declaration.py +++ b/hrms/payroll/doctype/employee_tax_exemption_declaration/test_employee_tax_exemption_declaration.py @@ -349,6 +349,7 @@ def test_india_hra_exemption_with_multiple_assignments(self): ) # salary structure with base 50000, HRA 3000 + # effective from 3 months before payroll period make_salary_structure( "Monthly Structure for HRA Exemption 1", "Monthly", @@ -356,7 +357,7 @@ def test_india_hra_exemption_with_multiple_assignments(self): company="_Test Company", currency="INR", payroll_period=payroll_period.name, - from_date=payroll_period.start_date, + from_date=add_months(payroll_period.start_date, -3), ) # salary structure with base 70000, HRA = base * 0.2 = 14000 @@ -379,6 +380,7 @@ def test_india_hra_exemption_with_multiple_assignments(self): salary_structure.submit() + # effective from 6 months after payroll period create_salary_structure_assignment( employee, salary_structure.name, diff --git a/hrms/payroll/doctype/gratuity/gratuity.py b/hrms/payroll/doctype/gratuity/gratuity.py index de25701a0d..9be57b0dc3 100644 --- a/hrms/payroll/doctype/gratuity/gratuity.py +++ b/hrms/payroll/doctype/gratuity/gratuity.py @@ -44,7 +44,7 @@ def set_status(self, update=False): else: status = "Unpaid" - if update: + if update and self.status != status: self.db_set("status", status) else: self.status = status @@ -249,6 +249,7 @@ def get_gratuity_amount(self, experience: float) -> float: years_left * total_component_amount * slab.fraction_of_applicable_earnings ) slab_found = True + break if not slab_found: frappe.throw( @@ -308,7 +309,7 @@ def get_gratuity_rule_slabs(self) -> list[dict]: ) def _is_experience_within_slab(self, slab: dict, experience: float) -> bool: - return bool(slab.from_year <= experience and (experience < slab.to_year or slab.to_year == 0)) + return bool(slab.from_year <= experience and (experience <= slab.to_year or slab.to_year == 0)) def _is_experience_beyond_slab(self, slab: dict, experience: float) -> bool: return bool(slab.from_year < experience and (slab.to_year < experience and slab.to_year != 0)) diff --git a/hrms/payroll/doctype/gratuity/test_gratuity.py b/hrms/payroll/doctype/gratuity/test_gratuity.py index ffb8285e3f..40a5af571f 100644 --- a/hrms/payroll/doctype/gratuity/test_gratuity.py +++ b/hrms/payroll/doctype/gratuity/test_gratuity.py @@ -90,19 +90,41 @@ def test_gratuity_based_on_current_slab_via_additional_salary(self): @set_holiday_list("Salary Slip Test Holiday List", "_Test Company") def test_gratuity_based_on_all_previous_slabs_via_payment_entry(self): """ - Range | Fraction - 0-1 | 0 - 1-5 | 0.7 - 5-0 | 1 + Range | Fraction + 0-3 | 0.5 + 3-6 | 1.0 + 6-9 | 1.5 """ from hrms.overrides.employee_payment_entry import get_payment_entry_for_employee sal_slip = create_salary_slip(self.employee) + rule = setup_gratuity_rule("Rule Under Limited Contract (UAE)") + rule.gratuity_rule_slabs = [] + for slab in [ + {"from_year": 0, "to_year": 3, "fraction_of_applicable_earnings": 0.5}, + {"from_year": 3, "to_year": 6, "fraction_of_applicable_earnings": 1.0}, + {"from_year": 6, "to_year": 9, "fraction_of_applicable_earnings": 1.5}, + ]: + new_slab = frappe.get_doc( + { + "doctype": "Gratuity Rule Slab", + "from_year": slab["from_year"], + "to_year": slab["to_year"], + "fraction_of_applicable_earnings": slab["fraction_of_applicable_earnings"], + "parent": rule.name, + "parentfield": "gratuity_rule_slabs", + "parenttype": "Gratuity Rule", + } + ) + rule.append("gratuity_rule_slabs", new_slab) + rule.save() + rule.reload() + set_mode_of_payment_account() gratuity = create_gratuity( - expense_account="Payment Account - _TC", mode_of_payment="Cash", employee=self.employee + expense_account="Payment Account - _TC", mode_of_payment="Cash", employee=self.employee, rule=rule ) # work experience calculation @@ -125,7 +147,7 @@ def test_gratuity_based_on_all_previous_slabs_via_payment_entry(self): limit=1, ) - gratuity_amount = ((0 * 1) + (4 * 0.7) + (1 * 1)) * component_amount[0].amount + gratuity_amount = ((3 * 0.5) + (3 * 1.0)) * component_amount[0].amount self.assertEqual(flt(gratuity_amount, 2), flt(gratuity.amount, 2)) self.assertEqual(gratuity.status, "Unpaid") @@ -171,6 +193,53 @@ def test_gratuity_amount_consistent_irrespective_of_payment_days(self): ) self.assertEqual(gratuity.amount, 190000.0) + @set_holiday_list("Salary Slip Test Holiday List", "_Test Company") + def test_settle_gratuity_via_fnf_statement(self): + from hrms.hr.doctype.full_and_final_statement.test_full_and_final_statement import ( + create_full_and_final_statement, + ) + + create_salary_slip(self.employee) + setup_gratuity_rule("Rule Under Limited Contract (UAE)") + set_mode_of_payment_account() + + # create gratuity + gratuity = create_gratuity( + expense_account="Payment Account - _TC", mode_of_payment="Cash", employee=self.employee + ) + gratuity.reload() + + # create Full and Final Statement and add gratuity as Payables + fnf = create_full_and_final_statement(self.employee) + fnf.payables = [] + fnf.receivables = [] + fnf.append( + "payables", + { + "component": "Gratuity", + "reference_document_type": "Gratuity", + "reference_document": gratuity.name, + "amount": gratuity.amount, + "account": gratuity.payable_account, + "status": "Settled", + }, + ) + fnf.submit() + + jv = fnf.create_journal_entry() + jv.accounts[1].account = frappe.get_cached_value("Company", "_Test Company", "default_bank_account") + jv.cheque_no = "123456" + jv.cheque_date = getdate() + jv.save() + jv.submit() + + gratuity.reload() + self.assertEqual(gratuity.status, "Paid") + + jv.cancel() + gratuity.reload() + self.assertEqual(gratuity.status, "Unpaid") + def setup_gratuity_rule(name: str) -> dict: from hrms.regional.united_arab_emirates.setup import setup @@ -201,6 +270,7 @@ def create_gratuity(**args): gratuity.expense_account = args.expense_account or "Payment Account - _TC" gratuity.payable_account = args.payable_account or get_payable_account("_Test Company") gratuity.mode_of_payment = args.mode_of_payment or "Cash" + gratuity.cost_center = args.cost_center or "Main - _TC" gratuity.save() gratuity.submit() diff --git a/hrms/payroll/doctype/payroll_entry/payroll_entry.js b/hrms/payroll/doctype/payroll_entry/payroll_entry.js index 1f59a6a40e..0947d034a0 100644 --- a/hrms/payroll/doctype/payroll_entry/payroll_entry.js +++ b/hrms/payroll/doctype/payroll_entry/payroll_entry.js @@ -334,11 +334,10 @@ frappe.ui.form.on("Payroll Entry", { salary_slip_based_on_timesheet: function (frm) { frm.toggle_reqd(["payroll_frequency"], !frm.doc.salary_slip_based_on_timesheet); - hrms.set_payroll_frequency_to_null(frm); }, set_start_end_dates: function (frm) { - if (!frm.doc.salary_slip_based_on_timesheet) { + if (frm.doc.payroll_frequency) { frappe.call({ method: "hrms.payroll.doctype.payroll_entry.payroll_entry.get_start_end_dates", args: { diff --git a/hrms/payroll/doctype/payroll_entry/payroll_entry.json b/hrms/payroll/doctype/payroll_entry/payroll_entry.json index c9c6cced2b..724ccb540c 100644 --- a/hrms/payroll/doctype/payroll_entry/payroll_entry.json +++ b/hrms/payroll/doctype/payroll_entry/payroll_entry.json @@ -62,7 +62,6 @@ "reqd": 1 }, { - "depends_on": "eval:doc.salary_slip_based_on_timesheet == 0", "fieldname": "payroll_frequency", "fieldtype": "Select", "label": "Payroll Frequency", @@ -336,7 +335,7 @@ "icon": "fa fa-cog", "is_submittable": 1, "links": [], - "modified": "2023-10-10 14:21:24.517349", + "modified": "2025-01-22 15:27:16.652848", "modified_by": "Administrator", "module": "Payroll", "name": "Payroll Entry", diff --git a/hrms/payroll/doctype/payroll_entry/payroll_entry.py b/hrms/payroll/doctype/payroll_entry/payroll_entry.py index f306f7a973..5abb387fd6 100644 --- a/hrms/payroll/doctype/payroll_entry/payroll_entry.py +++ b/hrms/payroll/doctype/payroll_entry/payroll_entry.py @@ -710,7 +710,9 @@ def set_payable_amount_against_payroll_payable_account( } """ for employee, employee_details in self.employee_based_payroll_payable_entries.items(): - payable_amount = employee_details.get("earnings", 0) - employee_details.get("deductions", 0) + payable_amount = (employee_details.get("earnings", 0) or 0) - ( + employee_details.get("deductions", 0) or 0 + ) payable_amount = self.get_accounting_entries_and_payable_amount( payroll_payable_account, @@ -1016,9 +1018,9 @@ def set_accounting_entries_for_bank_entry(self, je_payment_amount, user_remark): if self.employee_based_payroll_payable_entries: for employee, employee_details in self.employee_based_payroll_payable_entries.items(): je_payment_amount = ( - employee_details.get("earnings", 0) - - employee_details.get("deductions", 0) - - employee_details.get("total_loan_repayment", 0) + (employee_details.get("earnings", 0) or 0) + - (employee_details.get("deductions", 0) or 0) + - (employee_details.get("total_loan_repayment", 0) or 0) ) exchange_rate, amount = self.get_amount_and_exchange_rate_for_journal_entry( diff --git a/hrms/payroll/doctype/salary_slip/salary_slip.js b/hrms/payroll/doctype/salary_slip/salary_slip.js index 476ad134f0..2c9a550ff6 100644 --- a/hrms/payroll/doctype/salary_slip/salary_slip.js +++ b/hrms/payroll/doctype/salary_slip/salary_slip.js @@ -251,11 +251,6 @@ frappe.ui.form.on("Salary Slip", { ["hourly_wages", "timesheets"], cint(frm.doc.salary_slip_based_on_timesheet) === 1, ); - - frm.toggle_display( - ["payment_days", "total_working_days", "leave_without_pay"], - frm.doc.payroll_frequency != "", - ); }, get_emp_and_working_day_details: function (frm) { @@ -287,8 +282,8 @@ frappe.ui.form.on("Salary Slip", { const message = `
${__("Note").bold()}: ${__("Payment Days calculations are based on these Payroll Settings")}: -

${__("Payroll Based On")}: ${payroll_based_on.bold()} -
${__("Consider Unmarked Attendance As")}: ${consider_unmarked_attendance_as.bold()} +

${__("Payroll Based On")}: ${__(payroll_based_on).bold()} +
${__("Consider Unmarked Attendance As")}: ${__(consider_unmarked_attendance_as).bold()}
${__("Consider Marked Attendance on Holidays")}: ${ cint(include_holidays_in_total_working_days) && diff --git a/hrms/payroll/doctype/salary_slip/salary_slip.py b/hrms/payroll/doctype/salary_slip/salary_slip.py index a72b568b40..3d2aa1e7db 100644 --- a/hrms/payroll/doctype/salary_slip/salary_slip.py +++ b/hrms/payroll/doctype/salary_slip/salary_slip.py @@ -141,7 +141,7 @@ def validate(self): self.validate_dates() self.check_existing() - if not self.salary_slip_based_on_timesheet: + if self.payroll_frequency: self.get_date_details() if not (len(self.get("earnings")) or len(self.get("deductions"))): @@ -321,7 +321,7 @@ def get_emp_and_working_day_details(self): self.set("earnings", []) self.set("deductions", []) - if not self.salary_slip_based_on_timesheet: + if self.payroll_frequency: self.get_date_details() self.validate_dates() @@ -1678,7 +1678,7 @@ def get_taxable_earnings(self, allow_tax_exemption=False, based_on_payment_days= taxable_earnings -= flt(amount - additional_amount) additional_income -= additional_amount - amount_exempted_from_income_tax = flt(amount - additional_amount) + amount_exempted_from_income_tax += flt(amount - additional_amount) if additional_amount and ded.is_recurring_additional_salary: additional_income -= self.get_future_recurring_additional_amount( @@ -1913,7 +1913,7 @@ def set_status(self, status=None): def process_salary_structure(self, for_preview=0): """Calculate salary after salary structure details have been updated""" - if not self.salary_slip_based_on_timesheet: + if self.payroll_frequency: self.get_date_details() self.pull_emp_details() self.get_working_days_details(for_preview=for_preview) diff --git a/hrms/payroll/doctype/salary_slip/test_salary_slip.py b/hrms/payroll/doctype/salary_slip/test_salary_slip.py index 316838c56e..e5d08272a3 100644 --- a/hrms/payroll/doctype/salary_slip/test_salary_slip.py +++ b/hrms/payroll/doctype/salary_slip/test_salary_slip.py @@ -5,6 +5,7 @@ import random import frappe +from frappe.core.doctype.user_permission.test_user_permission import create_user from frappe.model.document import Document from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import ( @@ -371,8 +372,16 @@ def test_payment_days_based_on_leave_application(self): self.assertEqual(ss.payment_days, days_in_month - no_of_holidays - 3.75) @change_settings("Payroll Settings", {"payroll_based_on": "Leave"}) - def test_payment_days_calculation_for_varying_leave_ranges(self): - emp_id = make_employee("test_payment_days_based_on_leave_application@salary.com") + def test_payment_days_calculation_for_lwp_on_month_boundaries(self): + """Tests LWP calculation leave applications created on month boundaries""" + holiday_list = make_holiday_list( + "Test Holiday List", + "2024-01-01", + "2024-12-31", + ) + emp_id = make_employee( + "test_payment_days_based_on_leave_application@salary.com", holiday_list=holiday_list + ) make_leave_application(emp_id, "2024-06-28", "2024-07-03", "Leave Without Pay") # 3 days in July make_leave_application(emp_id, "2024-07-10", "2024-07-13", "Leave Without Pay") # 4 days in July @@ -2168,6 +2177,8 @@ def make_leave_application( half_day_date=None, submit=True, ): + create_user("test@example.com") + leave_application = frappe.get_doc( dict( doctype="Leave Application", diff --git a/hrms/payroll/doctype/salary_structure/salary_structure.js b/hrms/payroll/doctype/salary_structure/salary_structure.js index ddc1c46f28..dc10876cc3 100755 --- a/hrms/payroll/doctype/salary_structure/salary_structure.js +++ b/hrms/payroll/doctype/salary_structure/salary_structure.js @@ -175,7 +175,6 @@ frappe.ui.form.on("Salary Structure", { salary_slip_based_on_timesheet: function (frm) { frm.trigger("toggle_fields"); - hrms.set_payroll_frequency_to_null(frm); }, preview_salary_slip: function (frm) { diff --git a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py index 00dc6a5eba..01dcf89017 100644 --- a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py +++ b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py @@ -172,29 +172,7 @@ def are_opening_entries_required(self) -> bool: if not get_tax_component(self.salary_structure): return False - if self.has_emp_joined_after_payroll_period_start() and not self.has_existing_salary_slips(): - return True - else: - if not self.docstatus.is_draft() and ( - self.taxable_earnings_till_date or self.tax_deducted_till_date - ): - return True - return False - - def has_existing_salary_slips(self) -> bool: - return bool( - frappe.db.exists( - "Salary Slip", - {"employee": self.employee, "docstatus": 1}, - ) - ) - - def has_emp_joined_after_payroll_period_start(self) -> bool: - date_of_joining = getdate(frappe.db.get_value("Employee", self.employee, "date_of_joining")) - payroll_period = get_payroll_period(self.from_date, self.from_date, self.company) - if not payroll_period or date_of_joining > getdate(payroll_period.start_date): - return True - return False + return True def get_assigned_salary_structure(employee, on_date): diff --git a/hrms/payroll/doctype/salary_withholding/test_salary_withholding.py b/hrms/payroll/doctype/salary_withholding/test_salary_withholding.py index 5d4ad486cb..5910c35601 100644 --- a/hrms/payroll/doctype/salary_withholding/test_salary_withholding.py +++ b/hrms/payroll/doctype/salary_withholding/test_salary_withholding.py @@ -3,7 +3,7 @@ import frappe from frappe.tests.utils import FrappeTestCase -from frappe.utils import get_first_day, get_year_start, getdate +from frappe.utils import getdate from erpnext.setup.doctype.employee.test_employee import make_employee @@ -11,6 +11,12 @@ from hrms.payroll.doctype.payroll_entry.test_payroll_entry import make_payroll_entry from hrms.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure +COMPANY_NAME = "_Test Company" +MONTH_1_START = getdate("2024-01-01") +MONTH_1_END = getdate("2024-01-31") +MONTH_2_START = getdate("2024-02-01") +MONTH_2_END = getdate("2024-02-29") + class TestSalaryWithholding(FrappeTestCase): def setUp(self): @@ -26,28 +32,36 @@ def setUp(self): ]: frappe.db.delete(dt) - self.company = frappe.get_doc("Company", "_Test Company") - self.employee1 = make_employee("employee1@example.com", company=self.company, designation="Engineer") - self.employee2 = make_employee("employee2@example.com", company=self.company, designation="Engineer") + self.company = frappe.get_doc("Company", COMPANY_NAME) + self.employee1 = make_employee("employee1@example.com", company=COMPANY_NAME, designation="Engineer") + self.employee2 = make_employee("employee2@example.com", company=COMPANY_NAME, designation="Engineer") - self.today = getdate() - year_start = get_year_start(self.today) - make_salary_structure("Test Withholding", "Monthly", employee=self.employee1, from_date=year_start) - make_salary_structure("Test Withholding", "Monthly", employee=self.employee2, from_date=year_start) + make_salary_structure( + "Test Withholding", + "Monthly", + company=COMPANY_NAME, + employee=self.employee1, + from_date=MONTH_1_START, + ) + make_salary_structure( + "Test Withholding", + "Monthly", + company=COMPANY_NAME, + employee=self.employee2, + from_date=MONTH_1_START, + ) def test_set_withholding_cycles_and_to_date(self): - from_date = getdate("2024-06-01") - to_date = getdate("2024-07-31") - withholding = create_salary_withholding(self.employee1, from_date, 2) + withholding = create_salary_withholding(self.employee1, MONTH_1_START, 2) - self.assertEqual(withholding.to_date, to_date) - self.assertEqual(withholding.cycles[0].from_date, from_date) - self.assertEqual(withholding.cycles[0].to_date, getdate("2024-06-30")) - self.assertEqual(withholding.cycles[1].from_date, getdate("2024-07-01")) - self.assertEqual(withholding.cycles[1].to_date, to_date) + self.assertEqual(withholding.to_date, MONTH_2_END) + self.assertEqual(withholding.cycles[0].from_date, MONTH_1_START) + self.assertEqual(withholding.cycles[0].to_date, MONTH_1_END) + self.assertEqual(withholding.cycles[1].from_date, MONTH_2_START) + self.assertEqual(withholding.cycles[1].to_date, MONTH_2_END) def test_salary_withholding(self): - withholding = create_salary_withholding(self.employee1, get_first_day(self.today), 2) + withholding = create_salary_withholding(self.employee1, MONTH_1_START, 2) withholding.submit() payroll_entry = self._make_payroll_entry() @@ -61,7 +75,7 @@ def test_salary_withholding(self): self.assertEqual(withholding.status, "Withheld") def test_release_withheld_salaries(self): - withholding = create_salary_withholding(self.employee1, get_first_day(self.today), 2) + withholding = create_salary_withholding(self.employee1, MONTH_1_START, 2) withholding.submit() def test_run_payroll_for_cycle(withholding_cycle): @@ -106,7 +120,7 @@ def test_run_payroll_for_cycle(withholding_cycle): self.assertEqual(payroll_employee.is_salary_withheld, 1) def _make_payroll_entry(self, date: str | None = None): - dates = get_start_end_dates("Monthly", date or self.today) + dates = get_start_end_dates("Monthly", date or MONTH_1_START) return make_payroll_entry( start_date=dates.start_date, end_date=dates.end_date, @@ -117,7 +131,7 @@ def _make_payroll_entry(self, date: str | None = None): def _submit_bank_entry(self, bank_entry: dict): bank_entry.cheque_no = "123456" - bank_entry.cheque_date = self.today + bank_entry.cheque_date = MONTH_1_START bank_entry.submit() def _get_payroll_employee_row(self, payroll_entry: dict) -> dict | None: diff --git a/hrms/payroll/report/income_tax_computation/income_tax_computation.py b/hrms/payroll/report/income_tax_computation/income_tax_computation.py index d6a814ed23..0a50f8cde2 100644 --- a/hrms/payroll/report/income_tax_computation/income_tax_computation.py +++ b/hrms/payroll/report/income_tax_computation/income_tax_computation.py @@ -388,14 +388,12 @@ def get_standard_tax_exemption(self): ) ) - for emp, emp_details in self.employees.items(): - if not self.employees[emp]["allow_tax_exemption"]: - continue - + for emp_details in self.employees.values(): income_tax_slab = emp_details.get("income_tax_slab") standard_exemption = standard_exemptions_per_slab.get(income_tax_slab, 0) emp_details["standard_tax_exemption"] = standard_exemption - self.employees[emp]["total_exemption"] += standard_exemption + emp_details.setdefault("total_exemption", 0) + emp_details["total_exemption"] += standard_exemption self.add_column("Total Exemption") @@ -456,6 +454,20 @@ def get_data_for_eval(self, emp: str, emp_details: dict) -> tuple: return salary_slip.whitelisted_globals, eval_locals def get_total_deducted_tax(self): + SalaryComponent = frappe.qb.DocType("Salary Component") + tax_components = ( + frappe.qb.from_(SalaryComponent) + .select(SalaryComponent.name) + .where( + (SalaryComponent.is_income_tax_component == 1) + | (SalaryComponent.variable_based_on_taxable_salary == 1) + ) + .where(SalaryComponent.type == "Deduction") + .where(SalaryComponent.disabled == 0) + ).run(pluck="name") + if not tax_components: + return [] + self.add_column("Total Tax Deducted") ss = frappe.qb.DocType("Salary Slip") @@ -468,8 +480,8 @@ def get_total_deducted_tax(self): .select(ss.employee, Sum(ss_ded.amount).as_("amount")) .where(ss.docstatus == 1) .where(ss.employee.isin(list(self.employees.keys()))) + .where(ss_ded.salary_component.isin(tax_components)) .where(ss_ded.parentfield == "deductions") - .where(ss_ded.variable_based_on_taxable_salary == 1) .where(ss.start_date >= self.payroll_period_start_date) .where(ss.end_date <= self.payroll_period_end_date) .groupby(ss.employee) diff --git a/hrms/public/js/utils/index.js b/hrms/public/js/utils/index.js index b13082b8db..c2626999c4 100644 --- a/hrms/public/js/utils/index.js +++ b/hrms/public/js/utils/index.js @@ -34,7 +34,7 @@ $.extend(hrms, { } if (missing_fields.length) { - let message = __("Mandatory fields required for this action"); + let message = __("Mandatory fields required for this action:"); message += "

"; frappe.throw({ message: message, @@ -232,4 +232,47 @@ $.extend(hrms, { return autocompletions; }, + + add_shift_tools_button_to_list: (list_view, action = "Assign Shift") => { + list_view.page.add_inner_button( + __("Shift Assignment Tool"), + () => { + const doc = frappe.model.get_new_doc("Shift Assignment Tool"); + doc.action = action; + doc.company = frappe.defaults.get_default("company"); + doc.status = "Active"; + frappe.set_route("Form", "Shift Assignment Tool", doc.name); + }, + __("Shift Tools"), + ); + + list_view.page.add_inner_button( + __("Roster"), + () => { + window.location.href = "/hr/roster"; + }, + __("Shift Tools"), + ); + }, + + add_shift_tools_button_to_form: (frm, fields) => { + frm.add_custom_button( + __("Shift Assignment Tool"), + () => { + const doc = frappe.model.get_new_doc("Shift Assignment Tool"); + Object.assign(doc, fields); + doc.company = frappe.defaults.get_default("company"); + doc.status = "Active"; + frappe.set_route("Form", "Shift Assignment Tool", doc.name); + }, + __("Shift Tools"), + ); + frm.add_custom_button( + __("Roster"), + () => { + window.location.href = "/hr/roster"; + }, + __("Shift Tools"), + ); + }, }); diff --git a/hrms/regional/india/setup.py b/hrms/regional/india/setup.py index e2ebbb55cc..f7674634ac 100644 --- a/hrms/regional/india/setup.py +++ b/hrms/regional/india/setup.py @@ -84,7 +84,7 @@ def get_custom_fields(): "fieldname": "hra_section", "label": "HRA Settings", "fieldtype": "Section Break", - "insert_after": "asset_received_but_not_billed", + "insert_after": "default_payroll_payable_account", "collapsible": 1, }, { diff --git a/hrms/setup.py b/hrms/setup.py index 383b5118c0..ced82e2da9 100644 --- a/hrms/setup.py +++ b/hrms/setup.py @@ -595,6 +595,7 @@ def remove_lending_docperms_from_ess(): if row.document_type in loan_docperms: doc.user_doctypes.remove(row) + doc.flags.ignore_links = True doc.save(ignore_permissions=True) diff --git a/hrms/www/jobs/index.py b/hrms/www/jobs/index.py index 54cf4e2910..f8f100f134 100644 --- a/hrms/www/jobs/index.py +++ b/hrms/www/jobs/index.py @@ -9,7 +9,10 @@ def get_context(context): context.no_cache = 1 - context.parents = [{"name": _("My Account"), "route": "/"}] + if frappe.session.user == "Guest": + context.parents = [{"name": _("Home"), "route": "/"}] + else: + context.parents = [{"name": _("My Account"), "route": "/me"}] context.body_class = "jobs-page" page_len = 20 filters, txt, sort, offset = get_filters_txt_sort_offset(page_len) diff --git a/roster/package.json b/roster/package.json index f07daeeac3..570fac7b59 100644 --- a/roster/package.json +++ b/roster/package.json @@ -13,7 +13,7 @@ "autoprefixer": "^10.4.19", "dayjs": "^1.11.11", "feather-icons": "^4.29.1", - "frappe-ui": "^0.1.72", + "frappe-ui": "0.1.105", "postcss": "^8.4.5", "tailwindcss": "^3.4.3", "vite": "^5.4.10", diff --git a/roster/src/components/MonthViewHeader.vue b/roster/src/components/MonthViewHeader.vue index 41b2a38068..dcb77c3008 100644 --- a/roster/src/components/MonthViewHeader.vue +++ b/roster/src/components/MonthViewHeader.vue @@ -1,49 +1,44 @@