Control employee work entry by period
All checks were successful
Deploy production / deploy (push) Successful in 10m54s

This commit is contained in:
admin i 2026-11-04 15:29:27 +03:00
parent 70ce84c8a7
commit fc534e3bf3
15 changed files with 324 additions and 12 deletions

View File

@ -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"])

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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="Ввод выработки сотрудниками заблокирован"
),
),
]

View File

@ -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,

View File

@ -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",

View File

@ -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,

View File

@ -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,

View File

@ -1539,7 +1539,7 @@ export default function ProcurementPage() {
}
className="h-4 w-4 rounded accent-[var(--accent-primary)]"
/>
<Flag size={14} className="text-amber-500" />
<Flag size={14} className="text-red-500" />
Отложенные мной
</label>
{activeFilterCount > 0 && (
@ -1856,6 +1856,7 @@ export default function ProcurementPage() {
>
<Flag
size={15}
className="text-red-500"
fill={req.is_deferred ? "currentColor" : "none"}
/>
</button>
@ -1946,6 +1947,7 @@ export default function ProcurementPage() {
>
<Flag
size={15}
className="text-red-500"
fill={
selectedRequest.is_deferred
? "currentColor"
@ -2589,6 +2591,7 @@ export default function ProcurementPage() {
>
<Flag
size={15}
className="text-red-500"
fill={
req.is_deferred
? "currentColor"

View File

@ -643,7 +643,10 @@ export function PayrollDailyWorkSection({
{!selectedPeriod?.editable ? (
<div className="app-feedback-warning mt-4 rounded-lg px-3 py-2.5 text-sm">
Период закрыт для изменения выработки.
{selectedPeriod?.employee_work_entry_locked &&
selectedPeriod.status === "open"
? "Ввод выработки за этот период закрыт. Обратитесь к администрации."
: "Период закрыт для изменения выработки."}
</div>
) : null}
{error ? (

View File

@ -14,6 +14,8 @@ import {
FileCheck2,
FileSpreadsheet,
Loader2,
LockKeyhole,
LockKeyholeOpen,
Maximize2,
Minimize2,
Pencil,
@ -678,6 +680,8 @@ export function PayrollAdminWorkspace({
const [tableError, setTableError] = useState<string | null>(null);
const [tableVersion, setTableVersion] = useState(0);
const [notice, setNotice] = useState<string | null>(null);
const [workEntryLockBusy, setWorkEntryLockBusy] = useState(false);
const [workEntryLockError, setWorkEntryLockError] = useState<string | null>(null);
const [periodModalOpen, setPeriodModalOpen] = useState(false);
const [editingPeriod, setEditingPeriod] = useState<PayrollAdminPeriod | null>(
@ -1734,6 +1738,57 @@ export function PayrollAdminWorkspace({
selectedPeriodId={selectedPeriod?.id}
onChange={(periodId) => void loadWorkspace(periodId, true)}
/>
{selectedPeriod?.status === "open" ? (
<button
type="button"
disabled={workEntryLockBusy}
aria-pressed={Boolean(selectedPeriod.employee_work_entry_locked)}
title={
selectedPeriod.employee_work_entry_locked
? "Разрешить сотрудникам ввод выработки"
: "Закрыть сотрудникам ввод выработки"
}
aria-label={
selectedPeriod.employee_work_entry_locked
? "Разрешить сотрудникам ввод выработки"
: "Закрыть сотрудникам ввод выработки"
}
className={`inline-flex h-10 items-center gap-2 rounded-lg px-3 text-sm disabled:opacity-50 ${selectedPeriod.employee_work_entry_locked ? "app-selected" : "app-action-secondary"}`}
onClick={async () => {
const locked = !selectedPeriod.employee_work_entry_locked;
setWorkEntryLockBusy(true);
setWorkEntryLockError(null);
try {
await apiClient.setPayrollEmployeeWorkEntryLock(
selectedPeriod.id,
locked,
);
await refreshView(
locked
? "Ввод выработки сотрудниками закрыт."
: "Ввод выработки сотрудниками открыт.",
);
} catch (error) {
setWorkEntryLockError(
getPayrollAdminError(error, "Не удалось изменить доступ к выработке."),
);
} finally {
setWorkEntryLockBusy(false);
}
}}
>
{selectedPeriod.employee_work_entry_locked ? (
<LockKeyhole size={16} />
) : (
<LockKeyholeOpen size={16} />
)}
<span className="hidden xl:inline">
{selectedPeriod.employee_work_entry_locked
? "Выработка закрыта"
: "Выработка открыта"}
</span>
</button>
) : null}
{selectedPeriod?.status === "open" &&
permissions.manage_inputs &&
!workspace.current_run ? (
@ -1838,6 +1893,12 @@ export function PayrollAdminWorkspace({
</div>
) : null}
{workEntryLockError ? (
<div role="alert" className="app-feedback-danger rounded-xl px-4 py-3 text-sm">
{workEntryLockError}
</div>
) : null}
<div role="tablist" aria-label="Разделы управления зарплатой">
<HorizontalChipScroller ariaLabel="Разделы управления зарплатой">
{visibleTabs.map((item) => {

View File

@ -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, "Не удалось удалить комментарий."),

View File

@ -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}
</button>
@ -1724,12 +1726,14 @@ function CommentableValueCell({
commentCount,
commentLabel,
onOpenComment,
commentPreview,
}: {
children: ReactNode;
className?: string;
commentCount: number;
commentLabel: string;
onOpenComment: () => void;
commentPreview?: string;
}) {
return (
<td className={`${bodyCellClass} relative ${className}`}>
@ -1737,6 +1741,7 @@ function CommentableValueCell({
count={commentCount}
label={commentLabel}
onClick={onOpenComment}
preview={commentPreview}
/>
{children}
</td>
@ -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}
/>
<button
type="button"
@ -1828,6 +1836,7 @@ function InlineEditableCell({
automatic = false,
commentCount,
onOpenComment,
commentPreview,
cellClassName = "",
annotation,
}: {
@ -1838,6 +1847,7 @@ function InlineEditableCell({
automatic?: boolean;
commentCount: number;
onOpenComment: () => void;
commentPreview?: string;
cellClassName?: string;
annotation?: { label: string; title: string };
}) {
@ -1854,6 +1864,7 @@ function InlineEditableCell({
commentCount={commentCount}
commentLabel={label}
onOpenComment={onOpenComment}
commentPreview={commentPreview}
>
<span>{formattedValue}</span>
{annotation ? (
@ -1891,6 +1902,7 @@ function InlineEditableCell({
count={commentCount}
label={label}
onClick={onOpenComment}
preview={commentPreview}
/>
<label className="relative block min-h-10 w-full max-w-full overflow-hidden">
<input
@ -2039,7 +2051,7 @@ export function PayrollPeriodTableView({
const [activeCommentCell, setActiveCommentCell] =
useState<PayrollCommentCell | null>(null);
const [commentCountOverrides, setCommentCountOverrides] = useState<
Record<string, { count: number; periodId: number }>
Record<string, { count: number; periodId: number; latestText: string }>
>({});
const [breakdownEmployee, setBreakdownEmployee] = useState<{
id: number;
@ -2097,12 +2109,13 @@ export function PayrollPeriodTableView({
[hiddenRows, searchedRows],
);
const updateCommentCount = useCallback(
(cell: PayrollCommentCell, count: number) => {
(cell: PayrollCommentCell, count: number, latestText: string) => {
setCommentCountOverrides((current) => ({
...current,
[commentCellKey(cell.employeeId, cell.columnId)]: {
count,
periodId: cell.periodId,
latestText,
},
}));
},
@ -2360,15 +2373,24 @@ export function PayrollPeriodTableView({
summary.count,
]),
);
const commentPreviewByCell = new Map(
(data.cell_comments || []).map((summary) => [
commentCellKey(summary.employee_id, summary.column_id),
summary.latest_text || "",
]),
);
Object.entries(commentCountOverrides).forEach(([key, override]) => {
if (override.periodId === data.period_id)
if (override.periodId === data.period_id) {
commentCountByCell.set(key, override.count);
commentPreviewByCell.set(key, override.latestText);
}
});
const commentProps = (
row: PayrollPeriodTableRow,
columnId: string,
columnLabel: string,
) => ({
commentPreview: commentPreviewByCell.get(commentCellKey(row.employee.id, columnId)) || "",
commentCount:
commentCountByCell.get(commentCellKey(row.employee.id, columnId)) || 0,
onOpenComment: () =>
@ -3012,6 +3034,7 @@ export function PayrollPeriodTableView({
>
<CellCommentButton
count={comments.commentCount}
preview={comments.commentPreview}
label={`Остаток к переносу: ${row.employee.display_name}`}
onClick={comments.onOpenComment}
/>
@ -3058,6 +3081,7 @@ export function PayrollPeriodTableView({
>
<CellCommentButton
count={comments.commentCount}
preview={comments.commentPreview}
label={`Состояние: ${row.employee.display_name}`}
onClick={comments.onOpenComment}
/>
@ -3077,6 +3101,7 @@ export function PayrollPeriodTableView({
>
<CellCommentButton
count={comments.commentCount}
preview={comments.commentPreview}
label={`Состояние: ${row.employee.display_name}`}
onClick={comments.onOpenComment}
/>
@ -3754,6 +3779,7 @@ export function PayrollPeriodTableView({
style={{ left: REVIEW_MARK_COLUMN_WIDTH }}
>
<CellCommentButton
preview={commentPreviewByCell.get(commentCellKey(row.employee.id, "employee"))}
count={
commentCountByCell.get(
commentCellKey(row.employee.id, "employee"),

View File

@ -110,6 +110,7 @@ export type PayrollOwnDailyWorkEntry = {
export type PayrollOwnWorkPeriod = PayrollPeriodSummary & {
id: number;
employee_work_entry_locked?: boolean;
status: PayrollPeriodStatus;
status_label: string;
editable: boolean;
@ -207,6 +208,7 @@ export type PayrollManagementAccessSettings = {
export type PayrollAdminPeriod = PayrollPeriodSummary & {
id: number;
employee_work_entry_locked?: boolean;
currency: string;
status: PayrollPeriodStatus;
lock_version: number;
@ -450,6 +452,7 @@ export type PayrollCellCommentSummary = {
column_id: string;
count: number;
latest_at: string;
latest_text?: string;
};
export type PayrollAttendanceAlert = {
@ -1413,6 +1416,14 @@ export function createFinanceApi(request: RequestFn) {
`${adminPrefix}/periods/${periodId}/`,
jsonOptions("PATCH", payload),
),
setPayrollEmployeeWorkEntryLock: (
periodId: number,
locked: boolean,
): Promise<PayrollAdminPeriod> =>
request(
`${adminPrefix}/periods/${periodId}/`,
jsonOptions("POST", { employee_work_entry_locked: locked }),
),
getPayrollAdminPayRates: (
params: PayrollAdminListParams = {},
): Promise<PayrollAdminListPayload<PayrollAdminPayRate>> =>