diff --git a/backend/api/v1/finance/admin/period_views/catalog.py b/backend/api/v1/finance/admin/period_views/catalog.py index 932d9383..8ff43116 100644 --- a/backend/api/v1/finance/admin/period_views/catalog.py +++ b/backend/api/v1/finance/admin/period_views/catalog.py @@ -487,6 +487,38 @@ class PayrollAdminPeriodDetailView(PayrollAdminAPIView): period = get_object_or_404(PayrollPeriod, pk=pk) return Response(PayrollPeriodSerializer(period).data) + @transaction.atomic + def post(self, request, pk): + value = request.data.get("employee_work_entry_locked") + if not isinstance(value, bool): + raise ValidationError( + {"employee_work_entry_locked": "Укажите true или false."} + ) + period = get_object_or_404(PayrollPeriod.objects.select_for_update(), pk=pk) + if period.status != PayrollPeriodStatus.OPEN: + _conflict( + "WORK_PERIOD_LOCKED", + "Переключать ввод можно только для открытого периода.", + ) + if period.employee_work_entry_locked != value: + period.employee_work_entry_locked = value + period.lock_version += 1 + period.save( + update_fields=[ + "employee_work_entry_locked", + "lock_version", + "updated_at", + ] + ) + _audit_api_mutation( + actor=request.user, + action="payroll.employee_work_entry_lock_changed", + instance=period, + period=period, + metadata={"locked": value}, + ) + return Response(PayrollPeriodSerializer(period).data) + @transaction.atomic def patch(self, request, pk): self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"]) diff --git a/backend/api/v1/finance/admin/queries.py b/backend/api/v1/finance/admin/queries.py index bab98d54..bbd5c7f2 100644 --- a/backend/api/v1/finance/admin/queries.py +++ b/backend/api/v1/finance/admin/queries.py @@ -3,7 +3,7 @@ from decimal import Decimal from django.contrib.contenttypes.models import ContentType -from django.db.models import Count, Max +from django.db.models import Count, Max, OuterRef, Subquery from communications.models import Message from finance.balance_adjustments.services import locked_periods @@ -53,7 +53,20 @@ def _cell_comment_summaries(period): is_deleted=False, ) .values("chat__context_object_id") - .annotate(count=Count("id"), latest_at=Max("created_at")) + .annotate( + count=Count("id"), + latest_at=Max("created_at"), + latest_text=Subquery( + Message.objects.filter( + chat__type="comments", + chat__context_content_type=content_type, + chat__context_object_id=OuterRef("chat__context_object_id"), + is_deleted=False, + ) + .order_by("-created_at", "-id") + .values("content")[:1] + ), + ) ) result = [] for total in totals: @@ -64,6 +77,7 @@ def _cell_comment_summaries(period): "column_id": thread["column_id"], "count": total["count"], "latest_at": total["latest_at"], + "latest_text": total["latest_text"], } ) return result diff --git a/backend/api/v1/finance/payroll_serializers/periods.py b/backend/api/v1/finance/payroll_serializers/periods.py index 02931237..a8dc8306 100644 --- a/backend/api/v1/finance/payroll_serializers/periods.py +++ b/backend/api/v1/finance/payroll_serializers/periods.py @@ -45,6 +45,7 @@ class PayrollPeriodSerializer(serializers.ModelSerializer): "currency", "status", "status_label", + "employee_work_entry_locked", "current_run_id", "automatic_input_copy", "balance_rules_version", diff --git a/backend/api/v1/finance/views.py b/backend/api/v1/finance/views.py index e840174e..a1b3da08 100644 --- a/backend/api/v1/finance/views.py +++ b/backend/api/v1/finance/views.py @@ -290,6 +290,9 @@ class OwnPayrollWorkRecordView( else PayrollDailyWorkEntry.objects.none() ) daily_target_points = PayrollWorkSettings.get_daily_target_points() + from finance.payroll.access import can_access_payroll_management + + can_manage_work = can_access_payroll_management(request.user) schedule, schedule_source = resolve_employee_schedule(request.user) period_payloads = [] for period in periods: @@ -311,9 +314,11 @@ class OwnPayrollWorkRecordView( "pay_date": period.pay_date, "status": period.status, "status_label": period.get_status_display(), + "employee_work_entry_locked": period.employee_work_entry_locked, "editable": ( period.status == PayrollPeriodStatus.OPEN and period.date_from <= timezone.localdate() + and (not period.employee_work_entry_locked or can_manage_work) ), "record": ( OwnPayrollWorkRecordSerializer(record).data diff --git a/backend/finance/migrations/0043_payrollperiod_employee_work_entry_locked.py b/backend/finance/migrations/0043_payrollperiod_employee_work_entry_locked.py new file mode 100644 index 00000000..f0df7d04 --- /dev/null +++ b/backend/finance/migrations/0043_payrollperiod_employee_work_entry_locked.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("finance", "0042_input_copy_on_period_closure")] + + operations = [ + migrations.AddField( + model_name="payrollperiod", + name="employee_work_entry_locked", + field=models.BooleanField( + default=False, verbose_name="Ввод выработки сотрудниками заблокирован" + ), + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index af28abd1..2d965db7 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -112,6 +112,9 @@ class PayrollPeriod(models.Model): validators=[MinValueValidator(1)], ) pay_date = models.DateField("Дата выплаты", null=True, blank=True) + employee_work_entry_locked = models.BooleanField( + "Ввод выработки сотрудниками заблокирован", default=False + ) currency = models.CharField( "Валюта", max_length=3, diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py index f2336cd8..fd4dfa7c 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -42,6 +42,7 @@ from payroll_core import ( from .access import ( SELF_APPROVAL_OVERRIDE_PERMISSION, + can_access_payroll_management, can_self_approve_payroll, has_payroll_permission, has_simple_admin_access, @@ -1804,6 +1805,11 @@ def save_own_daily_work_entry( "Выработка за этот период уже закрыта для изменений.", period_status=period.status, ) + if period.employee_work_entry_locked and not can_access_payroll_management(actor): + _operation_error( + "WORK_ENTRY_LOCKED", + "Ввод выработки за этот период закрыт. Обратитесь к администрации.", + ) if not period.date_from <= work_date <= period.date_to: _operation_error( "WORK_DATE_OUTSIDE_PERIOD", diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index bf37854c..d894f72a 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -1572,10 +1572,35 @@ def test_period_table_cell_comments_use_shared_discussion_flow( assert cell_summary[0]["employee_id"] == employee.pk assert cell_summary[0]["column_id"] == "gross_total" assert cell_summary[0]["count"] == 1 + assert cell_summary[0]["latest_text"] == "Проверить итоговое начисление" assert parse_datetime(cell_summary[0]["latest_at"]) == parse_datetime( created.json()["created_at"] ) + colleague_client = auth_client_factory(colleague) + second = colleague_client.post( + comments_url, {"text": "Новое уточнение"}, format="json" + ) + assert second.status_code == 201 + summary = colleague_client.get(api_url("admin-period-table", pk=period.pk)).json()[ + "cell_comments" + ][0] + assert summary["count"] == 2 + assert summary["latest_text"] == "Новое уточнение" + second_url = api_url( + "admin-cell-comment-detail", + pk=period.pk, + employee_id=employee.pk, + column_id="gross_total", + comment_id=second.json()["id"], + ) + assert colleague_client.delete(second_url).status_code == 204 + summary = colleague_client.get(api_url("admin-period-table", pk=period.pk)).json()[ + "cell_comments" + ][0] + assert summary["count"] == 1 + assert summary["latest_text"] == "Проверить итоговое начисление" + detail_url = api_url( "admin-cell-comment-detail", pk=period.pk, diff --git a/backend/tests/finance/test_payroll_daily_work_api.py b/backend/tests/finance/test_payroll_daily_work_api.py index 68e45ef1..e7b1548e 100644 --- a/backend/tests/finance/test_payroll_daily_work_api.py +++ b/backend/tests/finance/test_payroll_daily_work_api.py @@ -7,6 +7,7 @@ from django.utils import timezone from attendance.models import EmployeeWorkSchedule, StandardWorkSchedule from finance.models import ( + FinanceSettings, PayrollAuditEvent, PayrollDailyWorkEntry, PayrollPeriod, @@ -314,6 +315,108 @@ def test_daily_work_rejects_locked_period_and_date_outside_period( assert not PayrollDailyWorkEntry.objects.filter(employee=employee).exists() +def test_management_can_lock_and_unlock_employee_work_entry( + user_factory, auth_client_factory, settings +): + settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": True} + employee = user_factory(email="work.lock.employee@example.test") + manager = user_factory(email="work.lock.manager@example.test", staff=True) + outsider = user_factory(email="work.lock.outsider@example.test", staff=True) + FinanceSettings.get_solo().management_users.set([manager]) + period = make_period(creator=manager) + user_client = auth_client_factory(employee) + manager_client = auth_client_factory(manager) + outsider_client = auth_client_factory(outsider) + admin_url = reverse( + "api:v1:finance-payroll:admin-period-detail", kwargs={"pk": period.pk} + ) + entry_payload = { + "period_id": period.pk, + "work_date": "2026-06-02", + "actual_points": "5", + } + + created = user_client.post(api_url(), entry_payload, format="json") + assert created.status_code == 201, created.content + entry_id = created.json()["entry"]["id"] + assert ( + outsider_client.post( + admin_url, {"employee_work_entry_locked": True}, format="json" + ).status_code + == 403 + ) + assert ( + manager_client.post( + admin_url, {"employee_work_entry_locked": True}, format="json" + ).status_code + == 200 + ) + + period.refresh_from_db() + assert period.employee_work_entry_locked is True + assert period.status == "open" + assert period.current_run_id is None + assert period.component_snapshot == {} + employee_period = user_client.get(api_url(), {"period_id": period.pk}).json()[ + "periods" + ][0] + manager_period = manager_client.get(api_url(), {"period_id": period.pk}).json()[ + "periods" + ][0] + assert employee_period["editable"] is False + assert manager_period["editable"] is True + blocked = user_client.post( + api_url(), + {**entry_payload, "actual_points": "8", "expected_lock_version": 0}, + format="json", + ) + assert blocked.status_code == 409 + assert blocked.json()["code"] == "WORK_ENTRY_LOCKED" + assert PayrollDailyWorkEntry.objects.get(pk=entry_id).actual_points == Decimal("5") + blocked_new = user_client.post( + api_url(), {**entry_payload, "work_date": "2026-06-03"}, format="json" + ) + assert blocked_new.status_code == 409 + assert not PayrollDailyWorkEntry.objects.filter( + employee=employee, work_date="2026-06-03" + ).exists() + manager_entry = manager_client.post(api_url(), entry_payload, format="json") + assert manager_entry.status_code == 201, manager_entry.content + + assert ( + manager_client.post( + admin_url, {"employee_work_entry_locked": False}, format="json" + ).status_code + == 200 + ) + saved = user_client.post( + api_url(), + {**entry_payload, "actual_points": "8", "expected_lock_version": 0}, + format="json", + ) + assert saved.status_code == 200, saved.content + assert PayrollDailyWorkEntry.objects.get(pk=entry_id).actual_points == Decimal("8") + assert ( + PayrollAuditEvent.objects.filter( + period=period, action="payroll.employee_work_entry_lock_changed" + ).count() + == 2 + ) + period.refresh_from_db() + period.status = "closed" + period.component_snapshot = {"columns": [{"code": "SNAPSHOT"}]} + period.save(update_fields=["status", "component_snapshot"]) + assert ( + manager_client.post( + admin_url, {"employee_work_entry_locked": True}, format="json" + ).status_code + == 409 + ) + period.refresh_from_db() + assert period.component_snapshot == {"columns": [{"code": "SNAPSHOT"}]} + assert period.employee_work_entry_locked is False + + def test_global_daily_target_applies_to_new_entries_and_preserves_history( user_factory, auth_client_factory, diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index bc80abc9..000ba032 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -1539,7 +1539,7 @@ export default function ProcurementPage() { } className="h-4 w-4 rounded accent-[var(--accent-primary)]" /> - + Отложенные мной {activeFilterCount > 0 && ( @@ -1856,6 +1856,7 @@ export default function ProcurementPage() { > @@ -1946,6 +1947,7 @@ export default function ProcurementPage() { > - Период закрыт для изменения выработки. + {selectedPeriod?.employee_work_entry_locked && + selectedPeriod.status === "open" + ? "Ввод выработки за этот период закрыт. Обратитесь к администрации." + : "Период закрыт для изменения выработки."} ) : null} {error ? ( diff --git a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx index d386d966..865c1a46 100644 --- a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx @@ -14,6 +14,8 @@ import { FileCheck2, FileSpreadsheet, Loader2, + LockKeyhole, + LockKeyholeOpen, Maximize2, Minimize2, Pencil, @@ -678,6 +680,8 @@ export function PayrollAdminWorkspace({ const [tableError, setTableError] = useState(null); const [tableVersion, setTableVersion] = useState(0); const [notice, setNotice] = useState(null); + const [workEntryLockBusy, setWorkEntryLockBusy] = useState(false); + const [workEntryLockError, setWorkEntryLockError] = useState(null); const [periodModalOpen, setPeriodModalOpen] = useState(false); const [editingPeriod, setEditingPeriod] = useState( @@ -1734,6 +1738,57 @@ export function PayrollAdminWorkspace({ selectedPeriodId={selectedPeriod?.id} onChange={(periodId) => void loadWorkspace(periodId, true)} /> + {selectedPeriod?.status === "open" ? ( + + ) : null} {selectedPeriod?.status === "open" && permissions.manage_inputs && !workspace.current_run ? ( @@ -1838,6 +1893,12 @@ export function PayrollAdminWorkspace({ ) : null} + {workEntryLockError ? ( +
+ {workEntryLockError} +
+ ) : null} +
{visibleTabs.map((item) => { diff --git a/frontend/src/components/finance/admin/PayrollCellCommentsModal.tsx b/frontend/src/components/finance/admin/PayrollCellCommentsModal.tsx index 0311a4fd..3ae3ca2c 100644 --- a/frontend/src/components/finance/admin/PayrollCellCommentsModal.tsx +++ b/frontend/src/components/finance/admin/PayrollCellCommentsModal.tsx @@ -30,7 +30,11 @@ type PayrollCellCommentsModalProps = { cell: PayrollCommentCell | null; currentUserId?: number | null; onClose: () => void; - onCommentCountChange: (cell: PayrollCommentCell, count: number) => void; + onCommentCountChange: ( + cell: PayrollCommentCell, + count: number, + latestText: string, + ) => void; periodId: number; }; @@ -71,7 +75,7 @@ export function PayrollCellCommentsModal({ activeCell.columnId, ); setComments(next); - onCommentCountChange(activeCell, next.length); + onCommentCountChange(activeCell, next.length, next.at(-1)?.text || ""); } catch (loadError) { setComments([]); setError( @@ -129,7 +133,7 @@ export function PayrollCellCommentsModal({ const next = [...comments, saved]; setComments(next); setDraft(""); - onCommentCountChange(cell, next.length); + onCommentCountChange(cell, next.length, next.at(-1)?.text || ""); } catch (sendError) { setError( getPayrollAdminError(sendError, "Не удалось отправить комментарий."), @@ -153,7 +157,7 @@ export function PayrollCellCommentsModal({ ); const next = comments.filter((item) => item.id !== comment.id); setComments(next); - onCommentCountChange(cell, next.length); + onCommentCountChange(cell, next.length, next.at(-1)?.text || ""); } catch (deleteError) { setError( getPayrollAdminError(deleteError, "Не удалось удалить комментарий."), diff --git a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx index aca5c9c6..615bafbb 100644 --- a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx +++ b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx @@ -1681,10 +1681,12 @@ function CellCommentButton({ count, label, onClick, + preview, }: { count: number; label: string; onClick: () => void; + preview?: string; }) { if (count) { return ( @@ -1696,7 +1698,7 @@ function CellCommentButton({ }} className="absolute right-1 top-1 z-10 rounded border border-sky-500/60 bg-sky-500/15 px-1 text-[9px] font-semibold leading-3 text-sky-400 transition hover:bg-sky-500/25 hover:text-sky-300" aria-label={`${label}: открыть комментарии, ${count}`} - title="Открыть комментарии" + title={preview || "Открыть комментарии"} > К:{count} @@ -1724,12 +1726,14 @@ function CommentableValueCell({ commentCount, commentLabel, onOpenComment, + commentPreview, }: { children: ReactNode; className?: string; commentCount: number; commentLabel: string; onOpenComment: () => void; + commentPreview?: string; }) { return ( @@ -1737,6 +1741,7 @@ function CommentableValueCell({ count={commentCount} label={commentLabel} onClick={onOpenComment} + preview={commentPreview} /> {children} @@ -1759,6 +1764,7 @@ function ProjectionPointsCell({ onOpen, commentCount, onOpenComment, + commentPreview, cellClassName = "", }: { value: string | null; @@ -1767,6 +1773,7 @@ function ProjectionPointsCell({ onOpen: () => void; commentCount: number; onOpenComment: () => void; + commentPreview?: string; cellClassName?: string; }) { const difference = comparePayrollProjection(value, actualPoints); @@ -1790,6 +1797,7 @@ function ProjectionPointsCell({ count={commentCount} label={label} onClick={onOpenComment} + preview={commentPreview} />