Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22e37b6e36 | |||
| f654db9bcf | |||
| 5cfe54f33b | |||
| 0c0104f6c9 | |||
| f664b468a2 | |||
| 2bf0ad1da7 | |||
| 6460a2a22d | |||
| edd5008120 | |||
| 5107f62e54 | |||
| 780d92cd3d | |||
| 023c0913f1 | |||
| e976b4e8d4 | |||
| 093bbecd9f | |||
| 59b0fbc569 | |||
| 984eb65f5b | |||
| b3c46f32d5 | |||
| 739f247268 | |||
| 4f0d02fe59 | |||
| ac1b66d0df | |||
| e811186cec | |||
| 91c66bbf81 | |||
| 7fce5a789c | |||
| ea6eb3663b | |||
| 5d2abaf430 | |||
| 99f3558e4a | |||
| a6f4218e7e | |||
| 96f53da020 | |||
| d6bb76fe47 | |||
| 4b05da5469 | |||
| 41cce93b41 | |||
| 664b58a44a | |||
| 755cfc8329 | |||
| e46632d67a | |||
| 8330790bf9 | |||
| af93ae57f4 | |||
| 79963e4035 |
@@ -17,6 +17,7 @@ const READ_VIA_POST = [
|
||||
/\/GetItems\b/i,
|
||||
/\/_api\/search\/postquery/i,
|
||||
/\/GetUpdatedFormDigest/i,
|
||||
/\/_api\/web\/GetClientSideComponents/i, // манифесты веб-партов, без них страница не грузит SPFx
|
||||
/\/_api\/SP\.Utilities\.Utility\.ResolvePrincipalInCurrentContext/i,
|
||||
/\/_api\/SP\.UI\.ApplicationPages\.ClientPeoplePickerWebServiceInterface\.clientPeoplePickerSearchUser/i,
|
||||
/\/_api\/sp\.userprofiles\.peoplemanager/i,
|
||||
|
||||
@@ -46,7 +46,9 @@ let AxeBuilder = null;
|
||||
try { ({ default: AxeBuilder } = await import('@axe-core/playwright')); } catch { /* axe не установлен */ }
|
||||
|
||||
await fs.mkdir(out, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
// Local Network Access в свежем Chromium режет загрузку https://localhost:4321 с публичного сайта SharePoint
|
||||
const LOCAL_BUILD_ARGS = ['--disable-features=LocalNetworkAccessChecks,PrivateNetworkAccessSendPreflights,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessRespectPreflightResults'];
|
||||
const browser = await chromium.launch(args['local-build'] ? { args: LOCAL_BUILD_ARGS } : {});
|
||||
const result = { url, surface: tenant ? 'tenant' : 'harness', date: new Date().toISOString(), axe: Boolean(AxeBuilder), runs: [] };
|
||||
|
||||
// Признаки непереведённых строк и утёкших значений в тексте
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; маршрут лентой (точки = город + объект из справочника + даты, участки с видом транспорта); задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены |
|
||||
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
||||
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
||||
| [`acc-payment-request`](../src/webparts/requestPortal/forms/PaymentRequest/README.md) | Заявка на оплату | Бухгалтерия | да | нет | да | основание — согласованная СТ (сайт СТ) или потребность; счёт PDF → библиотека → OCR → ИИ (реквизиты, суммы, позиции); сверка позиций счёта с СТ; подсветка чужого получателя и превышения общей суммы СТ; справочники 1С | ⏳ списки, библиотека, тип (id 24) и маршрут заведены; не проверена на живых данных |
|
||||
|
||||
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
|
||||
**Просмотр** — есть режим согласующего (`<Форма>View`).
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
EDO_API_BASE_URL_STAGE,
|
||||
setApprovalApiBaseUrl,
|
||||
setEdoApiBaseUrl,
|
||||
setNotificationSettings
|
||||
setNotificationSettings,
|
||||
setChatSelfNotify
|
||||
} from './shared/config';
|
||||
import { initListPartitions } from './shared/approval';
|
||||
import { ErrorOverlay } from './shared/ui';
|
||||
@@ -54,6 +55,11 @@ export interface IRequestPortalWebPartProps {
|
||||
notificationsEnabled?: boolean;
|
||||
/** Непустой адрес — письма только если активный шаг назначен на него (обкатка). Пусто — всем. */
|
||||
notifyOnlyTo?: string;
|
||||
/**
|
||||
* Для проверки чата: письма и о СВОИХ сообщениях, и можно отметить себя через «@».
|
||||
* По умолчанию выключено (undefined — тоже «выкл»).
|
||||
*/
|
||||
chatSelfNotify?: boolean;
|
||||
}
|
||||
|
||||
export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> {
|
||||
@@ -126,6 +132,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
||||
|
||||
private _applyNotificationSettings(): void {
|
||||
setNotificationSettings(this.properties.notificationsEnabled !== false, this.properties.notifyOnlyTo || '');
|
||||
setChatSelfNotify(this.properties.chatSelfNotify === true);
|
||||
}
|
||||
|
||||
private _getEnvironmentMessage(): Promise<string> {
|
||||
@@ -259,6 +266,12 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
||||
description: 'Для обкатки: письмо уходит, только если шаг назначен на этот e-mail. Пусто — всем.',
|
||||
placeholder: 'user@tnsu.kz',
|
||||
disabled: this.properties.notificationsEnabled === false
|
||||
}),
|
||||
PropertyPaneToggle('chatSelfNotify', {
|
||||
label: 'Чат: уведомлять о своих сообщениях (для теста)',
|
||||
onText: 'Да — и можно отметить себя через «@»',
|
||||
offText: 'Нет',
|
||||
checked: this.properties.chatSelfNotify === true
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
// Плитки одного ряда — одной высоты, даже если заголовок у соседа в 3 строки.
|
||||
height: 100%;
|
||||
padding: 22px 24px;
|
||||
text-align: left;
|
||||
background: $white;
|
||||
@@ -122,7 +124,9 @@
|
||||
.tileBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
// Без этого плашка «В разработке» растягивается на ширину заголовка.
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -468,7 +468,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
<SubmitFeature
|
||||
key={
|
||||
route.mode === 'create' && route.formKey
|
||||
? `create-${route.formKey}-${route.sourceId ?? route.positionId ?? 'blank'}`
|
||||
? `create-${route.formKey}-${route.sourceId ?? route.positionId ?? 'blank'}-${route.listId ?? ''}`
|
||||
: 'blank'
|
||||
}
|
||||
context={context}
|
||||
@@ -478,6 +478,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
? {
|
||||
formKey: route.formKey,
|
||||
sourceId: route.sourceId,
|
||||
sourceListId: route.listId,
|
||||
positionId: route.positionId,
|
||||
nomenclatureId: route.nomenclatureId,
|
||||
storeName: route.storeName,
|
||||
|
||||
@@ -53,6 +53,12 @@ export const REQUEST_TYPES: IRequestType[] = [
|
||||
title: 'Заявка на услуги',
|
||||
icon: 'document'
|
||||
},
|
||||
{
|
||||
id: 'acc-payment-request',
|
||||
categoryId: 'accounting',
|
||||
title: 'Заявка на оплату',
|
||||
icon: 'calculator'
|
||||
},
|
||||
{
|
||||
id: 'wh-concrete-request',
|
||||
categoryId: 'warehouse',
|
||||
|
||||
@@ -53,7 +53,7 @@ const ApproveFeature: React.FC<IApproveFeatureProps> = ({ context, formKey, item
|
||||
{/* Доступно всем, кто может открыть заявку: автору, всем согласующим
|
||||
(и уже решившим, и ещё ждущим своей очереди) и исполнителям —
|
||||
в отличие от панели решения, которая видна только текущему шагу.
|
||||
Пока включено только для «Обходного листа» — см. isChatEnabledFor. */}
|
||||
Включено не для всех типов заявок — см. isChatEnabledFor. */}
|
||||
{isChatEnabledFor(formKey) && (
|
||||
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
|
||||
)}
|
||||
|
||||
@@ -73,7 +73,7 @@ const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, li
|
||||
{/* Обсуждение — то же самое, что видят согласующие (комментарии к строке
|
||||
заявки). Здесь оно особенно нужно: заявку правят как раз после
|
||||
возврата на доработку, и уточнить причину логично не уходя с формы.
|
||||
Пока включено только для «Обходного листа» — см. isChatEnabledFor. */}
|
||||
Включено не для всех типов заявок — см. isChatEnabledFor. */}
|
||||
{isChatEnabledFor(formKey) && (
|
||||
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
|
||||
)}
|
||||
|
||||
@@ -143,7 +143,7 @@ interface ITableColumns {
|
||||
assignee: boolean;
|
||||
/** Колонка «Исполнено» — кто из исполнителей уже выполнил (см. showsExecuted). */
|
||||
executed: boolean;
|
||||
/** Колонка «Дата выхода» — только «Все заявки» по организации рабочего места. */
|
||||
/** Колонка «Дата выхода» — только таблица из заявок на организацию рабочего места (см. showsStartDate). */
|
||||
startDate?: boolean;
|
||||
/** Заголовок колонки с комментарием; undefined — колонки нет. */
|
||||
commentLabel?: string;
|
||||
@@ -178,9 +178,15 @@ function showsExecuted(view: ListView, statusFilter: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Дата выхода сотрудника есть только у заявки на организацию рабочего места, и показываем её во «Все заявки». */
|
||||
function showsStartDate(view: ListView, type: IRequestType | undefined): boolean {
|
||||
return view === 'all' && type?.pageUrl === WORKPLACE_SETUP_FORM_ID;
|
||||
/**
|
||||
* Дата выхода сотрудника есть только у заявки на организацию рабочего места.
|
||||
* Показываем её, когда таблица целиком из таких заявок: «Все заявки» этого типа
|
||||
* или любой раздел с фильтром по этому типу. Без фильтра типы вперемешку —
|
||||
* колонка была бы почти пустой.
|
||||
*/
|
||||
function showsStartDate(view: ListView, allRequestsType: IRequestType | undefined, filteredType: IRequestType | undefined): boolean {
|
||||
const tableType = view === 'all' ? allRequestsType : filteredType;
|
||||
return tableType?.pageUrl === WORKPLACE_SETUP_FORM_ID;
|
||||
}
|
||||
|
||||
function columnsFor(view: ListView, statusFilter: string, startDate = false): ITableColumns {
|
||||
@@ -194,15 +200,15 @@ function columnsFor(view: ListView, statusFilter: string, startDate = false): IT
|
||||
? 'Причина возврата'
|
||||
: undefined;
|
||||
|
||||
if (view === 'approvals' || view === 'execution') return { author: true, status: false, assignee, executed };
|
||||
if (view === 'rework') return { author: false, status: false, assignee, executed, commentLabel };
|
||||
if (view === 'own:created') return { author: false, status: !statusFilter, assignee, executed, commentLabel };
|
||||
if (view === 'approvals' || view === 'execution') return { author: true, status: false, assignee, executed, startDate };
|
||||
if (view === 'rework') return { author: false, status: false, assignee, executed, startDate, commentLabel };
|
||||
if (view === 'own:created') return { author: false, status: !statusFilter, assignee, executed, startDate, commentLabel };
|
||||
// 'all' — заявки всех авторов вперемешку, поэтому нужны и автор, и статус.
|
||||
if (view === 'all') return { author: true, status: true, assignee, executed, startDate };
|
||||
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он
|
||||
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
|
||||
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
|
||||
return { author: true, status: !decisionStatus, assignee, executed, commentLabel };
|
||||
return { author: true, status: !decisionStatus, assignee, executed, startDate, commentLabel };
|
||||
}
|
||||
|
||||
export interface IRequestsTableViewProps {
|
||||
@@ -494,6 +500,8 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
});
|
||||
const { localFilters, sort, isRefined } = refinement;
|
||||
const refinementSig = refinement.signature;
|
||||
const filteredType = types?.find((t) => t.id === localFilters.typeId);
|
||||
const withStartDate = showsStartDate(view, allRequestsType, filteredType);
|
||||
|
||||
// Полный список раздела для режима с фильтрами — последний загруженный.
|
||||
// Листание страниц и смена фильтра его переиспользуют (заново по сети не
|
||||
@@ -664,7 +672,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
decisionStatus ? hydrateDecisionComments(context, types, result.rows, decisionStatus) : undefined,
|
||||
showsAssignee(view, statusFilter) ? hydrateCurrentAssignees(context, types, result.rows) : undefined,
|
||||
showsExecuted(view, statusFilter) ? hydrateExecutedSteps(context, types, result.rows) : undefined,
|
||||
showsStartDate(view, allRequestsType) ? hydrateStartDates(context, result.rows) : undefined,
|
||||
withStartDate ? hydrateStartDates(context, result.rows) : undefined,
|
||||
]);
|
||||
return result;
|
||||
};
|
||||
@@ -850,7 +858,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
rows={visible}
|
||||
view={view}
|
||||
statusFilter={statusFilter}
|
||||
withStartDate={showsStartDate(view, allRequestsType)}
|
||||
withStartDate={withStartDate}
|
||||
sort={sort}
|
||||
onSort={refinement.onSort}
|
||||
onOpen={onOpen}
|
||||
|
||||
@@ -33,6 +33,7 @@ function asDay(value: unknown): string {
|
||||
}
|
||||
|
||||
/** Разобрать сохранённое значение; всё, что не прошло проверку, — как «не задано». */
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- так отдаёт localStorage.getItem, если ключа нет
|
||||
export function parseStoredRefinement(raw: string | null): IStoredRefinement | undefined {
|
||||
if (!raw) return undefined;
|
||||
let parsed: { filters?: Record<string, unknown>; sort?: Record<string, unknown> };
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface ISubmitFeatureProps {
|
||||
initialForm?: {
|
||||
formKey: string;
|
||||
sourceId?: number;
|
||||
/** Партиция исходной заявки (rplist) — см. IRequestFormProps.sourceListId. */
|
||||
sourceListId?: string;
|
||||
positionId?: string;
|
||||
nomenclatureId?: string;
|
||||
storeName?: string;
|
||||
@@ -148,6 +150,7 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
|
||||
onCancel={handleBack}
|
||||
onSaved={emitRequestsChanged}
|
||||
sourceId={sourceId}
|
||||
sourceListId={sourceId ? external?.sourceListId : undefined}
|
||||
editId={editId}
|
||||
positionId={external?.positionId}
|
||||
nomenclatureId={external?.nomenclatureId}
|
||||
|
||||
@@ -27,8 +27,10 @@ import {
|
||||
IApproverToWrite,
|
||||
IUseMatrixEditor,
|
||||
unassignedStepMessage,
|
||||
resolveWriteListId
|
||||
resolveWriteListId,
|
||||
composeRemoveRowRule
|
||||
} from '../../shared/approval';
|
||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||
import { IRequestFormProps } from '../../models/types';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import {
|
||||
@@ -244,7 +246,11 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
|
||||
<ObligationSection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
|
||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
||||
<ApprovalMatrix
|
||||
editor={matrix}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||
/>
|
||||
|
||||
{!executors.loading && executors.rows.length > 0 && (
|
||||
<ApprovalMatrix
|
||||
@@ -253,6 +259,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
|
||||
title="Исполнение (после согласования)"
|
||||
roleLabel="Исполнитель"
|
||||
emptyText="Исполнители не заданы по маршруту."
|
||||
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -342,6 +342,7 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
|
||||
title="Исполнение (после согласования)"
|
||||
roleLabel="Исполнитель"
|
||||
emptyText="Исполнители не заданы по маршруту."
|
||||
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
margin-bottom: $sp-4;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $navy-700;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
cursor: pointer;
|
||||
transition: color .18s;
|
||||
|
||||
&:hover {
|
||||
color: $gold-600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import styles from './PaymentRequestForm.module.scss';
|
||||
import {
|
||||
Alert,
|
||||
CollapseSection,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
Icon,
|
||||
RequestNumber,
|
||||
SubmitActions,
|
||||
getDraftDeleteHandler,
|
||||
useRequestSubmit
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
composeRemoveRowRule,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
loadRoutePhase,
|
||||
isOpenedRouteReady,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
REQUEST_STATUS,
|
||||
isEditableStatus,
|
||||
IApproverToWrite,
|
||||
unassignedStepMessage,
|
||||
resolveWriteListId,
|
||||
useFormExecutors,
|
||||
ExecutorsMatrix,
|
||||
executorsToWrite,
|
||||
initialSubmitStatus
|
||||
} from '../../shared/approval';
|
||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import { todayIsoDate } from '../../shared/dateUtils';
|
||||
import { IRequestFormProps } from '../../models/types';
|
||||
import { approvalListTitle, approvalListTitleForListId, dataListTitle, dataListTitleForListId } from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { savePaymentRequest, paymentRequestApprovalService } from './submit';
|
||||
import { loadPaymentRequest } from './load';
|
||||
import { loadPreviousPayments } from './services/previousPayments.service';
|
||||
import { validatePayment } from './validation';
|
||||
import { useBasisSelection } from './useBasisSelection';
|
||||
import { useInvoiceFlow } from './useInvoiceFlow';
|
||||
import { HeaderSection } from './sections/HeaderSection';
|
||||
import { BasisSection } from './sections/BasisSection';
|
||||
import { PaymentSection } from './sections/PaymentSection';
|
||||
import { AmountsSection } from './sections/AmountsSection';
|
||||
import { InvoiceSection } from './sections/InvoiceSection';
|
||||
import { PositionsSection } from './sections/PositionsSection';
|
||||
|
||||
const PaymentRequestForm: React.FC<IRequestFormProps> = ({
|
||||
requestType,
|
||||
context,
|
||||
onCancel,
|
||||
onSaved,
|
||||
editId,
|
||||
editListId,
|
||||
onCodeLoaded
|
||||
}) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||
const submit = useRequestSubmit();
|
||||
|
||||
const readOnly = !isEditableStatus(requestStatus);
|
||||
|
||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||
const patch = useCallback((changes: Partial<IFormData>): void => setData((prev) => ({ ...prev, ...changes })), []);
|
||||
|
||||
const basis = useBasisSelection(context, patch, requestId);
|
||||
const invoice = useInvoiceFlow(context, data, patch);
|
||||
|
||||
// Новая заявка — предзаполняем заявителя текущим пользователем и дату сегодняшней.
|
||||
useEffect(() => {
|
||||
if (editId) return undefined;
|
||||
const requesterEmail = effectiveUserEmail(context);
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
RequesterEmail: requesterEmail,
|
||||
RequesterName: context.pageContext.user.displayName || '',
|
||||
RequestDate: prev.RequestDate || todayIsoDate()
|
||||
}));
|
||||
let alive = true;
|
||||
getEmployeeOrganizations(context, requesterEmail)
|
||||
.then((records) => {
|
||||
if (alive) {
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
Organization: formatEmployeeOrganizations(records),
|
||||
RequesterJobTitle: prev.RequesterJobTitle || records[0]?.position || '',
|
||||
Department: prev.Department || records[0]?.section || '',
|
||||
DepartmentId: prev.DepartmentId || records[0]?.departmentId || ''
|
||||
}));
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [editId, context]);
|
||||
|
||||
// Выбор другого заявителя подтягивает его ФИО, должность, подразделение и организации.
|
||||
const handleRequesterChange = (login: string): void => {
|
||||
patch({ RequesterEmail: login });
|
||||
if (!login) {
|
||||
patch({ Organization: '' });
|
||||
return;
|
||||
}
|
||||
getEmployeeOrganizations(context, login)
|
||||
.then((records) =>
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
RequesterName: records[0]?.fio || prev.RequesterName,
|
||||
RequesterJobTitle: records[0]?.position || prev.RequesterJobTitle,
|
||||
Department: records[0]?.section || prev.Department,
|
||||
DepartmentId: records[0]?.departmentId || prev.DepartmentId,
|
||||
Organization: formatEmployeeOrganizations(records)
|
||||
}))
|
||||
)
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
// Открыта СВОЯ заявка на редактирование — подгружаем содержимое и статус.
|
||||
useEffect(() => {
|
||||
if (!editId) return undefined;
|
||||
let alive = true;
|
||||
loadPaymentRequest(editId, editListId)
|
||||
.then((loaded) => {
|
||||
if (!alive) return;
|
||||
setData(loaded.data);
|
||||
setRequestStatus(loaded.status);
|
||||
setRequestCode(loaded.code || undefined);
|
||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
||||
// В элементе лежит снимок прежних оплат на момент сохранения. Пока заявку
|
||||
// ещё правят (черновик, возврат), перечитываем их: по основанию могли
|
||||
// подать новые, а лимит суммы должен считаться по актуальным.
|
||||
if (isEditableStatus(loaded.status) && loaded.data.BasisKey) {
|
||||
loadPreviousPayments(context, loaded.data.BasisKey, editId)
|
||||
.then((previous) => {
|
||||
if (alive) patch({ PreviousPayments: previous });
|
||||
})
|
||||
.catch(() => undefined); // остаётся снимок — лучше, чем пустая таблица
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editId, editListId]);
|
||||
|
||||
const approval = useMemo(() => paymentRequestApprovalService(editListId), [editListId]);
|
||||
const userEmail = data.RequesterEmail;
|
||||
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||
const requestTypeId = requestTypeIdState.id;
|
||||
// Маршрут зависит от проекта — пока он не выбран, resolve вернёт 404.
|
||||
const projectId = data.ProjectId || null;
|
||||
const matrixReady =
|
||||
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!userEmail && !!projectId && !!requestTypeId));
|
||||
const loadMatrix = useCallback(
|
||||
() =>
|
||||
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
|
||||
getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, undefined, projectId)
|
||||
),
|
||||
[context, userEmail, approval, editId, requestStatus, projectId, requestTypeId]
|
||||
);
|
||||
const matrix = useMatrixEditor(loadMatrix, `${userEmail}-${editId ?? 'new'}-${projectId}`, !readOnly, matrixReady, (login) =>
|
||||
approval.getJobTitle(login)
|
||||
);
|
||||
const executors = useFormExecutors({
|
||||
readOnly,
|
||||
context,
|
||||
approval,
|
||||
requestTypeId,
|
||||
userEmail: matrixReady ? userEmail : '',
|
||||
editId,
|
||||
requestStatus,
|
||||
projectId
|
||||
});
|
||||
|
||||
const formError = validatePayment(data);
|
||||
const isValid = !formError;
|
||||
const validate = (): string | undefined => {
|
||||
if (formError) return formError;
|
||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign, order: r.order }));
|
||||
|
||||
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||
const rtId = requestTypeId;
|
||||
if (!rtId) {
|
||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||
}
|
||||
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||
const { id, code } = await savePaymentRequest(requestId, rtId, data, toApprovers(matrix.rows), executorsToWrite(executors.rows), status, writeListId);
|
||||
setRequestId(id);
|
||||
setRequestStatus(status);
|
||||
if (code) setRequestCode(code);
|
||||
if (onSaved) onSaved();
|
||||
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||
await notifyActiveStep(context, approval, id, {
|
||||
requestTypeId: rtId,
|
||||
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateSubmit = (): string | undefined => {
|
||||
const error = validate();
|
||||
if (error) return error;
|
||||
if (matrix.error) {
|
||||
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||
}
|
||||
return (
|
||||
unassignedStepMessage(
|
||||
matrix.rows,
|
||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||
) ||
|
||||
unassignedStepMessage(
|
||||
executors.rows,
|
||||
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||
const handleSubmit = (): Promise<void> =>
|
||||
submit.run(
|
||||
'submit',
|
||||
save(executors.rows.length > 0 ? initialSubmitStatus(matrix.rows.length, executors.rows.length) : REQUEST_STATUS.onApproval),
|
||||
validateSubmit
|
||||
);
|
||||
|
||||
const sectionProps = { data, set, patch, context, onRequesterChange: handleRequesterChange };
|
||||
|
||||
const handleDelete = getDraftDeleteHandler({
|
||||
formKey: FORM_ID,
|
||||
requestId,
|
||||
requestStatus,
|
||||
listId: editListId,
|
||||
code: requestCode,
|
||||
submit,
|
||||
onDeleted: onCancel
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" className={styles.back} onClick={onCancel}>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
<span>К списку заявок</span>
|
||||
</button>
|
||||
|
||||
<FormCard
|
||||
title={requestType.title}
|
||||
subtitle="Категория: Бухгалтерия"
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
|
||||
itemId={requestId}
|
||||
fallback={{ name: data.RequesterName, email: data.RequesterEmail }}
|
||||
/>
|
||||
}
|
||||
headerRight={
|
||||
requestId || requestStatus ? (
|
||||
<>
|
||||
{requestStatus && <StatusBadge status={requestStatus} />}
|
||||
{requestCode && <RequestNumber code={requestCode} />}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{submit.savedKind && !submit.error && (
|
||||
<Alert variant="ok">{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}</Alert>
|
||||
)}
|
||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||
|
||||
<FormModeProvider readOnly={readOnly}>
|
||||
<HeaderSection {...sectionProps} />
|
||||
<CollapseSection title="Основание" icon="document">
|
||||
<BasisSection {...sectionProps} basis={basis} />
|
||||
</CollapseSection>
|
||||
<CollapseSection title="Вложения — счёт на оплату" icon="document">
|
||||
<InvoiceSection data={data} invoice={invoice} />
|
||||
</CollapseSection>
|
||||
<CollapseSection title="Реквизиты платежа" icon="calculator">
|
||||
<PaymentSection {...sectionProps} />
|
||||
</CollapseSection>
|
||||
<CollapseSection title="Суммы" icon="calculator">
|
||||
<AmountsSection {...sectionProps} />
|
||||
</CollapseSection>
|
||||
<CollapseSection title="Позиции" icon="clipboard">
|
||||
<PositionsSection rows={data.Positions} compared={!!data.InvoiceFileName} />
|
||||
</CollapseSection>
|
||||
</FormModeProvider>
|
||||
|
||||
<ApprovalMatrix
|
||||
editor={matrix}
|
||||
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
waiting={!matrixReady}
|
||||
waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования."
|
||||
/>
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={editListId || dataListTitle(FORM_ID)} requestId={editId} reloadKey={requestStatus} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
isValid={isValid}
|
||||
readOnly={readOnly}
|
||||
onCancel={onCancel}
|
||||
onSaveDraft={handleSaveDraft}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</FormCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentRequestForm;
|
||||
@@ -0,0 +1,17 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.state {
|
||||
padding: $sp-8;
|
||||
color: $gray-500;
|
||||
font-size: $fs-sm;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: $sp-3;
|
||||
margin-bottom: $sp-4;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './PaymentRequestView.module.scss';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
CollapseSection,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
PrintSplitButton,
|
||||
OrgLetterhead,
|
||||
Icon
|
||||
} from '../../shared/ui';
|
||||
import { navigate } from '../../shared/routing';
|
||||
import {
|
||||
ApproverService,
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
useViewExecutors,
|
||||
ExecutorsMatrix,
|
||||
approvalPhaseRows,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
isEditableStatus
|
||||
} from '../../shared/approval';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import { useStableCallback } from '../../shared/useStableCallback';
|
||||
import { approvalListTitle, approvalListTitleForListId, dataListTitle, dataListTitleForListId } from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { loadPaymentRequest } from './load';
|
||||
import { paymentRequestApprovalService } from './submit';
|
||||
import { HeaderSection } from './sections/HeaderSection';
|
||||
import { PaymentSection } from './sections/PaymentSection';
|
||||
import { AmountsSection } from './sections/AmountsSection';
|
||||
import { InvoiceSection } from './sections/InvoiceSection';
|
||||
import { PositionsSection } from './sections/PositionsSection';
|
||||
import { IInvoiceFlow } from './useInvoiceFlow';
|
||||
|
||||
interface IPaymentRequestViewProps {
|
||||
context: WebPartContext;
|
||||
itemId: number;
|
||||
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
|
||||
formKey: string;
|
||||
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
|
||||
listId?: string;
|
||||
/** Уведомление о загруженном уникальном коде заявки — см. IApprovalViewProps.onCodeLoaded. */
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
/** В просмотре счёт не загружается: поток нужен только секции как заглушка пропсов. */
|
||||
const IDLE_INVOICE: IInvoiceFlow = { stage: 'idle', error: '', attach: () => Promise.resolve() };
|
||||
|
||||
// Заявка на оплату только для просмотра (режим согласующего).
|
||||
const PaymentRequestView: React.FC<IPaymentRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||
const notifyCode = useStableCallback(onCodeLoaded);
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
|
||||
|
||||
const { id: requestTypeId } = useRequestTypeId(context, formKey);
|
||||
const set: SetField = () => undefined;
|
||||
const patch = (): void => undefined;
|
||||
|
||||
const approval = useMemo(() => paymentRequestApprovalService(listId), [listId]);
|
||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
const loadApprovers = React.useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l));
|
||||
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
loadPaymentRequest(itemId, listId)
|
||||
.then((loaded) => {
|
||||
if (!alive) return;
|
||||
setData(loaded.data);
|
||||
setStatus(loaded.status);
|
||||
setCode(loaded.code);
|
||||
notifyCode(loaded.code);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) {
|
||||
setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [itemId, listId, notifyCode]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.state}>
|
||||
<Alert variant="err">{error}</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sectionProps = { data, set, patch, context, printOrgIndex };
|
||||
|
||||
const notify = (): void => {
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
{isEditableStatus(status) && (
|
||||
<Button variant="secondary" onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId, listId })}>
|
||||
<Icon name="edit" size="sm" />
|
||||
Редактировать
|
||||
</Button>
|
||||
)}
|
||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||
</div>
|
||||
|
||||
<div className="rp-print-root">
|
||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||
<FormCard
|
||||
title="Заявка на оплату"
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={<InitiatorLine listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)} itemId={itemId} />}
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
<FormModeProvider readOnly>
|
||||
<HeaderSection {...sectionProps} />
|
||||
<CollapseSection title="Основание" icon="document">
|
||||
<div>
|
||||
{data.BasisTitle} · {data.ProjectName} · {data.CompanyName}
|
||||
</div>
|
||||
</CollapseSection>
|
||||
<CollapseSection title="Вложения — счёт на оплату" icon="document">
|
||||
<InvoiceSection data={data} invoice={IDLE_INVOICE} />
|
||||
</CollapseSection>
|
||||
<CollapseSection title="Реквизиты платежа" icon="calculator">
|
||||
<PaymentSection {...sectionProps} />
|
||||
</CollapseSection>
|
||||
<CollapseSection title="Суммы" icon="calculator">
|
||||
<AmountsSection {...sectionProps} />
|
||||
</CollapseSection>
|
||||
<CollapseSection title="Позиции" icon="clipboard">
|
||||
<PositionsSection rows={data.Positions} compared={!!data.InvoiceFileName} />
|
||||
</CollapseSection>
|
||||
</FormModeProvider>
|
||||
</FormCard>
|
||||
|
||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
requestId={itemId}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentRequestView;
|
||||
@@ -0,0 +1,40 @@
|
||||
# Заявка на оплату (`acc-payment-request`)
|
||||
|
||||
**Назначение:** оплата по согласованной СТ (сравнительной таблице) или по потребности — счёт, получатель, суммы и сверка позиций счёта с СТ.
|
||||
**Категория:** Бухгалтерия
|
||||
**Списки SP:** «Заявка на оплату — данные», «Заявка на оплату — согласование»; библиотека «Счета на оплату» (создаётся вручную, колонка `file_content` — многострочный текст)
|
||||
**RequestTypeId (внешний API маршрутов):** резолвится в рантайме по `pageUrl` (см. `docs/forms.md`); заведён тип 24, раздел «Бухгалтерия», список данных `/sites/requests/Lists/120`
|
||||
|
||||
## Маршрут
|
||||
- Фазы: согласование (исполнителей нет)
|
||||
- Маршрут зависит от проекта (`ProjectId`) — как у «Заявки на услуги»: пока проект не выбран, матрица не строится.
|
||||
- Маршрут 25 (`byProject: true`, без компании и подразделения — для любого проекта): один шаг согласования, фиксированный человек `gorbenko.pavel@tnsu.kz`. Форма всегда шлёт `projectId`, поэтому маршрута с `byProject: false` нет.
|
||||
|
||||
## Особое поведение
|
||||
- **Основание** — согласованная СТ (`status = Согласовано` в списке «Сравнительная таблица» сайта СТ, `CT_SITE_URL`) либо потребность (`GET /Requests/365`, позиции — `/Requests/{oid}/positions`; тот же источник, что шаг 1 СТ). Выбор подставляет проект, компанию (если известна), получателя, раздел, вид работ, условия, общую сумму и позиции (`basisPatch.ts`); всё остаётся редактируемым.
|
||||
- **Получатель** — выигравший поставщик СТ (`winSupplier` → «Поставщики»). Если в счёте указан другой контрагент (БИН, иначе название без формы собственности) — поле подсвечивается (`recipientMatchesInvoice`).
|
||||
- **Раздел / вид работ** у СТ — из позиций СТ (`disciplineCode` / `SMR`), у потребности — `discipline` / `workType` заявки.
|
||||
- **Условия оплаты, доставки и срок** — из «Критериев» СТ по победителю.
|
||||
- **Общая сумма** СТ — сумма предложений победителя в «Оценке поставщиков» (смешанные валюты — в тенге). У потребности её нет, берётся из счёта.
|
||||
- **Сумма оплаты** может быть меньше общей (транши). Сумма всех оплат по основанию (прежние + эта) не может превышать общую — при превышении поле подсвечивается, отправку это **не** блокирует.
|
||||
- **Ранее поданные заявки на оплату** — по `BasisKey` (`ct:<Id СТ>` / `need:<oid>`) через approval-route `GET /api/basis-requests` по всем партициям, без черновиков, отклонённых и возвращённых. Сервер читает от имени приложения, поэтому видны и чужие заявки (права на элементы разорваны поштучно); номер становится ссылкой, только если у пользователя есть права открыть заявку. Снимок пишется в `PreviousPaymentsJson`; черновик и возвращённая при открытии перечитывают список заново.
|
||||
- **Счёт (PDF)**: сначала сохраняется в библиотеку «Счета на оплату», затем `POST /Document/extractPDFText` (OCR записывает текст в `file_content` до ответа, колонка читается сразу), затем ИИ (`/Proxy/Gemini`) достаёт номер счёта, поставщика, БИН, IBAN, валюту, суммы и позиции и подставляет их (назначение платежа — «Оплата по счёту № …»). При сбое OCR/ИИ счёт остаётся приложенным, поля заполняются вручную.
|
||||
- **Таблица позиций**: код, наименование, ед. изм. и количество — из СТ/потребности; позиция поставщика — из его КП; позиция из счёта. ИИ сопоставляет позиции по смыслу, а количество и единицы сверяет код (`positions.ts`). Статусы: совпало / расхождение / нет в счёте / нет в СТ. Результат пишется в `PositionsJson`.
|
||||
- **Код позиции потребности** — `lineNumber` (номер строки табличной части 1С, уникален в пределах заявки).
|
||||
- Справочники 1С (`api-tnsu-portal`, `DictionaryController`) — `DictionaryPicker`; недоступный справочник не блокирует заявку (поле становится текстовым):
|
||||
- **Банковский счёт** — `GET /Dictionary/BankAccounts?search=&companyId=&projectId=` (постранично, поиск на сервере по названию, коду, номеру счёта, банку). Сначала по компании и проекту; если по проекту пусто — повтор только по компании с пояснением под полем (совпадение Id проекта из `/Project` с `bank_accounts.project_id` не гарантировано).
|
||||
- **Договор** — `GET /Dictionary/Contracts?search=` (только действующие; фильтра по компании нет, поиск по названию, коду и контрагенту).
|
||||
- **Группа статьи ДДС** — `GET /Dictionary/CashFlowItemGroups` (целиком); **статья ДДС** — `GET /Dictionary/CashFlowItems?id=<группа>`.
|
||||
- Фильтры-Id передаются, только если это GUID: иначе API отвечает 400.
|
||||
|
||||
## Что не проверено на живом API
|
||||
- Совпадают ли Id компании из `/Dictionary/Companies` и проекта из `/Project` с `company_id` / `project_id` в `bank_accounts` (на это — откат на фильтр по компании).
|
||||
- Доступ пользователей портала на чтение списков сайта СТ.
|
||||
|
||||
## Секции формы
|
||||
HeaderSection, BasisSection (BasisPicker), InvoiceSection, PaymentSection, AmountsSection, PositionsSection
|
||||
|
||||
## Точки входа
|
||||
- rptype: `acc-payment-request`
|
||||
- Режим согласующего (просмотр): есть (`PaymentRequestView`)
|
||||
- «Создать на основе»: —
|
||||
@@ -0,0 +1,66 @@
|
||||
import { formatAmountInput, overLimitAmount, parseAmount, previousPaymentsTotal, recipientMatchesInvoice } from './amounts';
|
||||
|
||||
describe('parseAmount', () => {
|
||||
it('понимает пробелы и запятую', () => {
|
||||
expect(parseAmount('1 234,50')).toBe(1234.5);
|
||||
});
|
||||
it('мусор и пустая строка — 0', () => {
|
||||
expect(parseAmount('abc')).toBe(0);
|
||||
expect(parseAmount('')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('overLimitAmount', () => {
|
||||
const prev = [
|
||||
{ code: 'A', amount: 600 },
|
||||
{ code: 'B', amount: 100 }
|
||||
];
|
||||
it('не превышает, пока сумма всех оплат не больше общей', () => {
|
||||
expect(overLimitAmount({ TotalAmount: 1000, PayAmount: 300, PreviousPayments: prev })).toBe(0);
|
||||
});
|
||||
it('возвращает величину превышения', () => {
|
||||
expect(overLimitAmount({ TotalAmount: 1000, PayAmount: 400, PreviousPayments: prev })).toBe(100);
|
||||
});
|
||||
it('копейки округления не считаются превышением', () => {
|
||||
expect(overLimitAmount({ TotalAmount: 1000, PayAmount: 300.004, PreviousPayments: prev })).toBe(0);
|
||||
});
|
||||
it('без общей суммы сравнивать не с чем', () => {
|
||||
expect(overLimitAmount({ TotalAmount: 0, PayAmount: 5, PreviousPayments: prev })).toBe(0);
|
||||
});
|
||||
it('считает сумму прежних оплат', () => {
|
||||
expect(previousPaymentsTotal(prev)).toBe(700);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recipientMatchesInvoice', () => {
|
||||
it('БИН решает, когда он есть у обеих сторон', () => {
|
||||
expect(recipientMatchesInvoice({ name: 'А', bin: '1' }, { name: 'Б', bin: '1' })).toBe(true);
|
||||
expect(recipientMatchesInvoice({ name: 'А', bin: '1' }, { name: 'А', bin: '2' })).toBe(false);
|
||||
});
|
||||
it('без БИН сравнивает названия без формы собственности и кавычек', () => {
|
||||
expect(recipientMatchesInvoice({ name: 'ТОО «Альфа Строй»', bin: '' }, { name: 'Альфа Строй, ТОО', bin: '' })).toBe(true);
|
||||
expect(recipientMatchesInvoice({ name: 'ТОО «Альфа»', bin: '' }, { name: 'ТОО «Бета»', bin: '' })).toBe(false);
|
||||
});
|
||||
it('нет данных по счёту — расхождения нет', () => {
|
||||
expect(recipientMatchesInvoice({ name: 'ТОО «Альфа»', bin: '1' }, { name: '', bin: '' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAmountInput', () => {
|
||||
it('разбивает на разряды', () => {
|
||||
expect(formatAmountInput('1234567')).toBe('1 234 567');
|
||||
expect(formatAmountInput('1 528 642')).toBe('1 528 642');
|
||||
});
|
||||
it('держит недописанную дробную часть и режет её до двух знаков', () => {
|
||||
expect(formatAmountInput('1234,')).toBe('1 234,');
|
||||
expect(formatAmountInput('1234.567')).toBe('1 234,56');
|
||||
expect(formatAmountInput(',5')).toBe('0,5');
|
||||
});
|
||||
it('выкидывает мусор и лишние разделители', () => {
|
||||
expect(formatAmountInput('12a3,4,5')).toBe('123,45');
|
||||
expect(formatAmountInput('')).toBe('');
|
||||
});
|
||||
it('согласован с parseAmount', () => {
|
||||
expect(parseAmount(formatAmountInput('154100,5'))).toBe(154100.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { IFormData, IPreviousPayment } from './model';
|
||||
|
||||
/** Допуск при сравнении денег: копейки округления не считаем превышением. */
|
||||
const MONEY_EPSILON = 0.005;
|
||||
|
||||
/** Число из текста поля ввода: пробелы и запятая допустимы; мусор → 0. */
|
||||
export function parseAmount(raw: string): number {
|
||||
const value = Number(raw.replace(/\s/g, '').replace(',', '.'));
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Текст поля суммы с разрядами («1234567,5» → «1 234 567,5»), как его видно при
|
||||
* вводе: остаются цифры и один десятичный разделитель (запятая), в дробной
|
||||
* части не больше двух знаков. Недописанное «12,» сохраняется — иначе запятую
|
||||
* нельзя было бы ввести.
|
||||
*/
|
||||
export function formatAmountInput(raw: string): string {
|
||||
const cleaned = raw.replace(/[^\d.,]/g, '').replace('.', ',');
|
||||
const comma = cleaned.indexOf(',');
|
||||
const intPart = (comma < 0 ? cleaned : cleaned.slice(0, comma)).replace(/,/g, '').replace(/^0+(?=\d)/, '');
|
||||
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
if (comma < 0) return grouped;
|
||||
const fraction = cleaned.slice(comma + 1).replace(/,/g, '').slice(0, 2);
|
||||
return `${grouped || '0'},${fraction}`;
|
||||
}
|
||||
|
||||
/** Сумма ранее поданных оплат по основанию. */
|
||||
export function previousPaymentsTotal(payments: IPreviousPayment[]): number {
|
||||
return payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* На сколько все оплаты по основанию (прежние + эта) превышают общую сумму СТ.
|
||||
* 0 — не превышают; общая сумма не задана — сравнивать не с чем, тоже 0.
|
||||
*/
|
||||
export function overLimitAmount(data: Pick<IFormData, 'TotalAmount' | 'PayAmount' | 'PreviousPayments'>): number {
|
||||
if (data.TotalAmount <= 0) return 0;
|
||||
const over = previousPaymentsTotal(data.PreviousPayments) + data.PayAmount - data.TotalAmount;
|
||||
return over > MONEY_EPSILON ? over : 0;
|
||||
}
|
||||
|
||||
/** Сумма с разрядами для показа («1 234 567,5»). */
|
||||
export function formatAmount(value: number): string {
|
||||
return value.toLocaleString('ru-RU', { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
const LEGAL_FORMS = /(^|\s)(тоо|ао|ип|llp|ооо|товарищество с ограниченной ответственностью)(?=\s|$)/gi;
|
||||
|
||||
/** Название контрагента для сравнения: без формы собственности, кавычек и регистра. */
|
||||
export function normalizeCounterparty(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-zа-яё0-9]+/gi, ' ')
|
||||
.replace(LEGAL_FORMS, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Совпадает ли получатель из СТ с поставщиком из счёта. БИН надёжнее названия:
|
||||
* если он есть у обеих сторон — решает он. Нет данных по счёту — расхождения нет
|
||||
* (подсвечивать нечего).
|
||||
*/
|
||||
export function recipientMatchesInvoice(
|
||||
recipient: { name: string; bin: string },
|
||||
invoice: { name: string; bin: string }
|
||||
): boolean {
|
||||
if (!invoice.name && !invoice.bin) return true;
|
||||
if (recipient.bin && invoice.bin) return recipient.bin === invoice.bin;
|
||||
const a = normalizeCounterparty(recipient.name);
|
||||
const b = normalizeCounterparty(invoice.name);
|
||||
if (!a || !b) return true;
|
||||
return a === b || a.indexOf(b) !== -1 || b.indexOf(a) !== -1;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"dataListTitle": "Заявка на оплату — согласование",
|
||||
"columns": [
|
||||
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
|
||||
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Заявка на оплату — данные", "lookupShowField": "Title" },
|
||||
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
||||
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
||||
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
||||
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
|
||||
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
|
||||
{ "internalName": "AutoApprove", "title": "Автосогласование", "type": "Boolean" },
|
||||
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ICompany, IProject } from '../../shared/dictionary';
|
||||
import { normalizeCounterparty } from './amounts';
|
||||
import { IFormData, IPreviousPayment } from './model';
|
||||
import { rowsFromBasis } from './positions';
|
||||
import { IBasisInfo } from './services/basis.types';
|
||||
|
||||
/** Проект из справочника по названию: в СТ и потребности проект хранится только текстом. */
|
||||
function findProject(projects: IProject[], name: string): IProject | undefined {
|
||||
const wanted = name.trim().toLowerCase();
|
||||
return wanted ? projects.filter((p) => p.name.trim().toLowerCase() === wanted)[0] : undefined;
|
||||
}
|
||||
|
||||
/** Компания из справочника по подсказке (организация потребности). */
|
||||
function findCompany(companies: ICompany[], hint: string): ICompany | undefined {
|
||||
const wanted = normalizeCounterparty(hint);
|
||||
if (!wanted) return undefined;
|
||||
return companies.filter((c) => normalizeCounterparty(c.Name) === wanted)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Поля формы, которые подставляет выбранное основание. Всё подставленное остаётся
|
||||
* редактируемым: справочники не всегда точны, а заявитель лучше знает, что платит.
|
||||
* Счёт и сумма оплаты основанием не затрагиваются.
|
||||
*/
|
||||
export function basisPatch(
|
||||
info: IBasisInfo,
|
||||
previous: IPreviousPayment[],
|
||||
projects: IProject[],
|
||||
companies: ICompany[]
|
||||
): Partial<IFormData> {
|
||||
const project = findProject(projects, info.projectName);
|
||||
const company = findCompany(companies, info.companyHint);
|
||||
return {
|
||||
BasisKind: info.kind,
|
||||
BasisKey: info.key,
|
||||
BasisTitle: info.title,
|
||||
ProjectName: info.projectName,
|
||||
ProjectId: project ? project.oid : '',
|
||||
ProjectManager: project ? project.projectManager || '' : '',
|
||||
CompanyId: company ? company.Id : '',
|
||||
CompanyName: company ? company.Name : '',
|
||||
RecipientName: info.recipientName,
|
||||
RecipientBin: info.recipientBin,
|
||||
Section: info.section,
|
||||
WorkKind: info.workKind,
|
||||
TotalAmount: info.total,
|
||||
Currency: info.currency,
|
||||
DeliveryTerms: info.deliveryTerms,
|
||||
PaymentTerms: info.paymentTerms,
|
||||
DeliveryTime: info.deliveryTime,
|
||||
PreviousPayments: previous,
|
||||
Positions: rowsFromBasis(info.positions)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { IFormData, IPositionRow } from './model';
|
||||
import { buildRows, IInvoiceItem, IPositionPair } from './positions';
|
||||
import { IInvoiceData } from './services/invoiceAi.service';
|
||||
import { IBasisPosition } from './services/basis.types';
|
||||
|
||||
/** Назначение платежа по номеру счёта. */
|
||||
export function purposeForInvoice(invoiceNumber: string): string {
|
||||
return invoiceNumber ? `Оплата по счёту № ${invoiceNumber}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Позиции основания из уже собранной таблицы (строки «нет в СТ» — это позиции
|
||||
* счёта, к основанию они не относятся). Нужны, чтобы повторно сверить таблицу с
|
||||
* другим счётом, не перезагружая СТ.
|
||||
*/
|
||||
export function basisPositionsFromRows(rows: IPositionRow[]): IBasisPosition[] {
|
||||
return rows
|
||||
.filter((row) => row.status !== 'extra')
|
||||
.map((row) => ({
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
unit: row.unit,
|
||||
qty: row.qty,
|
||||
supplierName: row.supplierName,
|
||||
supplierQty: row.supplierQty,
|
||||
supplierPrice: row.supplierPrice
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Что счёт подставляет в форму. Пустое значение из счёта поле не затирает.
|
||||
* Общая сумма берётся из счёта только когда основание её не дало (потребность):
|
||||
* у СТ общая сумма — предел для всех оплат, и счёт его менять не должен.
|
||||
* Получатель у потребности — поставщик из счёта; у СТ — победитель, и расхождение
|
||||
* со счётом подсвечивается, а не затирается.
|
||||
*/
|
||||
export function invoicePatch(
|
||||
data: IFormData,
|
||||
invoice: IInvoiceData,
|
||||
items: IInvoiceItem[],
|
||||
pairs: IPositionPair[]
|
||||
): Partial<IFormData> {
|
||||
const patch: Partial<IFormData> = {
|
||||
InvoiceNumber: invoice.invoiceNumber,
|
||||
InvoiceSupplierName: invoice.supplierName,
|
||||
InvoiceSupplierBin: invoice.supplierBin,
|
||||
Positions: buildRows(basisPositionsFromRows(data.Positions), items, pairs)
|
||||
};
|
||||
if (invoice.recipientAccount) patch.RecipientAccount = invoice.recipientAccount;
|
||||
if (invoice.currency) patch.Currency = invoice.currency;
|
||||
if (invoice.invoiceNumber) patch.PaymentPurpose = purposeForInvoice(invoice.invoiceNumber);
|
||||
if (invoice.vatAmount > 0) patch.VatAmount = invoice.vatAmount;
|
||||
if (invoice.totalAmount > 0) {
|
||||
patch.PayAmount = invoice.totalAmount;
|
||||
if (data.TotalAmount <= 0) patch.TotalAmount = invoice.totalAmount;
|
||||
}
|
||||
if (!data.RecipientName && invoice.supplierName) {
|
||||
patch.RecipientName = invoice.supplierName;
|
||||
patch.RecipientBin = invoice.supplierBin;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { getSP } from '../../shared/pnp/pnpjs';
|
||||
import { dataListTitle } from '../../shared/listTitles';
|
||||
import { spDateOnlyToIso } from '../../shared/dateUtils';
|
||||
import { EMPTY_DATA, FORM_ID, IFormData, IPositionRow, IPreviousPayment, BasisKind } from './model';
|
||||
|
||||
export interface ILoadedRequest {
|
||||
data: IFormData;
|
||||
status: string;
|
||||
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** Текстовые колонки, которые читаются как есть (имя поля формы = имя колонки). */
|
||||
const TEXT_KEYS = [
|
||||
'RequesterEmail',
|
||||
'RequesterName',
|
||||
'RequesterJobTitle',
|
||||
'Organization',
|
||||
'Department',
|
||||
'DepartmentId',
|
||||
'BasisKey',
|
||||
'BasisTitle',
|
||||
'ProjectName',
|
||||
'ProjectId',
|
||||
'ProjectManager',
|
||||
'CompanyId',
|
||||
'CompanyName',
|
||||
'BankAccountId',
|
||||
'BankAccountName',
|
||||
'RecipientName',
|
||||
'RecipientBin',
|
||||
'RecipientAccount',
|
||||
'Section',
|
||||
'WorkKind',
|
||||
'ContractId',
|
||||
'ContractName',
|
||||
'CashFlowGroupId',
|
||||
'CashFlowGroupName',
|
||||
'CashFlowItemId',
|
||||
'CashFlowItemName',
|
||||
'PaymentPurpose',
|
||||
'DeliveryTerms',
|
||||
'PaymentTerms',
|
||||
'Currency',
|
||||
'DeliveryTime',
|
||||
'Comment',
|
||||
'InvoiceFileName',
|
||||
'InvoiceUrl',
|
||||
'InvoiceNumber',
|
||||
'InvoiceSupplierName',
|
||||
'InvoiceSupplierBin'
|
||||
] as const;
|
||||
|
||||
const NUMBER_KEYS = ['TotalAmount', 'VatAmount', 'PayAmount'] as const;
|
||||
|
||||
const SELECT_FIELDS = [
|
||||
'Id',
|
||||
'RequestDate',
|
||||
'BasisKind',
|
||||
'PreviousPaymentsJson',
|
||||
'PositionsJson',
|
||||
'RequestStatus',
|
||||
'RequestUniqueCode',
|
||||
...TEXT_KEYS,
|
||||
...NUMBER_KEYS
|
||||
];
|
||||
|
||||
/**
|
||||
* Безопасный разбор JSON-колонки табличной части: пустой массив при пустом
|
||||
* значении или битом JSON — заявка должна открываться даже с испорченной
|
||||
* таблицей, иначе её нельзя было бы починить через форму.
|
||||
*/
|
||||
function parseRows<T>(raw: unknown): T[] {
|
||||
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Прочитать «Заявку на оплату» по id: модель формы + статус + код.
|
||||
* listId — партиция, где лежит именно эта заявка; не задан — текущая.
|
||||
*/
|
||||
export async function loadPaymentRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
|
||||
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||
const item = (await list.items.getById(itemId).select(...SELECT_FIELDS)()) as Record<string, unknown>;
|
||||
|
||||
const data: IFormData = {
|
||||
...EMPTY_DATA,
|
||||
RequestDate: spDateOnlyToIso(item.RequestDate),
|
||||
BasisKind: item.BasisKind === 'need' ? ('need' as BasisKind) : 'ct',
|
||||
PreviousPayments: parseRows<IPreviousPayment>(item.PreviousPaymentsJson),
|
||||
Positions: parseRows<IPositionRow>(item.PositionsJson)
|
||||
};
|
||||
TEXT_KEYS.forEach((key) => {
|
||||
data[key] = (item[key] as string) || '';
|
||||
});
|
||||
NUMBER_KEYS.forEach((key) => {
|
||||
data[key] = Number(item[key] || 0);
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
status: (item.RequestStatus as string) || '',
|
||||
code: (item.RequestUniqueCode as string) || ''
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { RequestTypeId } from '../../models/types';
|
||||
|
||||
/**
|
||||
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
|
||||
* SharePoint выводятся из него, см. shared/listTitles.ts.
|
||||
*/
|
||||
export const FORM_ID: RequestTypeId = 'acc-payment-request';
|
||||
|
||||
/** На чём основана оплата: согласованная СТ либо потребность (когда СТ не было). */
|
||||
export type BasisKind = 'ct' | 'need';
|
||||
|
||||
export const BASIS_OPTIONS: Array<{ value: BasisKind; label: string }> = [
|
||||
{ value: 'ct', label: 'Сравнительная таблица (СТ)' },
|
||||
{ value: 'need', label: 'Потребность' }
|
||||
];
|
||||
|
||||
/** Ранее поданная заявка на оплату по той же СТ/потребности. */
|
||||
export interface IPreviousPayment {
|
||||
/** Уникальный код заявки на оплату. */
|
||||
code: string;
|
||||
amount: number;
|
||||
/** Id элемента списка данных — для ссылки на заявку. Нет, если у пользователя нет прав её открыть (или в старом снимке). */
|
||||
id?: number;
|
||||
/** Список партиции (серверный относительный URL), где лежит заявка; '' — текущая. */
|
||||
listId?: string;
|
||||
/** Статус заявки на момент чтения. */
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат сверки строки с позициями счёта:
|
||||
* ok — совпало; diff — позиция та же, но отличается количество/ед. изм.;
|
||||
* missing — есть в СТ/потребности, в счёте нет; extra — есть в счёте, в СТ нет.
|
||||
*/
|
||||
export type RowStatus = 'ok' | 'diff' | 'missing' | 'extra';
|
||||
|
||||
/** Строка таблицы позиций: СТ/потребность ↔ поставщик ↔ счёт. */
|
||||
export interface IPositionRow {
|
||||
/** Уникальный код позиции потребности. */
|
||||
code: string;
|
||||
/** Наименование и ед. изм. из потребности (в СТ они есть). */
|
||||
name: string;
|
||||
unit: string;
|
||||
qty: number;
|
||||
/** Позиция поставщика из его КП (только при основании «СТ»). */
|
||||
supplierName: string;
|
||||
supplierQty: number;
|
||||
supplierPrice: number;
|
||||
/** Позиция из счёта. */
|
||||
invoiceName: string;
|
||||
invoiceUnit: string;
|
||||
invoiceQty: number;
|
||||
invoicePrice: number;
|
||||
invoiceSum: number;
|
||||
status: RowStatus;
|
||||
/** Пояснение к расхождению; '' — нет. */
|
||||
note: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Данные формы «Заявка на оплату». Ключи = internalName колонок SP-списка
|
||||
* «Заявка на оплату — данные». Табличные части уходят в JSON-колонки
|
||||
* (PreviousPaymentsJson, PositionsJson), суммы — в числовые колонки (для отчётов).
|
||||
*/
|
||||
export interface IFormData {
|
||||
// --- Заявитель ---
|
||||
RequesterEmail: string;
|
||||
RequesterName: string;
|
||||
RequesterJobTitle: string;
|
||||
Organization: string;
|
||||
Department: string;
|
||||
DepartmentId: string;
|
||||
RequestDate: string;
|
||||
|
||||
// --- Основание ---
|
||||
BasisKind: BasisKind;
|
||||
/** Ключ основания для поиска прежних оплат: «ct:<Id СТ>» либо «need:<oid потребности>». */
|
||||
BasisKey: string;
|
||||
/** Подпись основания: «СТ №3 — Проект» либо «Потребность 081/26-04/0017». */
|
||||
BasisTitle: string;
|
||||
ProjectName: string;
|
||||
/** oid проекта: по нему бэкенд резолвит маршрут согласования. */
|
||||
ProjectId: string;
|
||||
ProjectManager: string;
|
||||
CompanyId: string;
|
||||
CompanyName: string;
|
||||
|
||||
// --- Платёж ---
|
||||
BankAccountId: string;
|
||||
BankAccountName: string;
|
||||
RecipientName: string;
|
||||
RecipientBin: string;
|
||||
/** Счёт получателя (IBAN) — из файла счёта. */
|
||||
RecipientAccount: string;
|
||||
Section: string;
|
||||
WorkKind: string;
|
||||
ContractId: string;
|
||||
ContractName: string;
|
||||
CashFlowGroupId: string;
|
||||
CashFlowGroupName: string;
|
||||
CashFlowItemId: string;
|
||||
CashFlowItemName: string;
|
||||
PaymentPurpose: string;
|
||||
DeliveryTerms: string;
|
||||
PaymentTerms: string;
|
||||
|
||||
// --- Суммы ---
|
||||
/** Суммы хранятся числом: 0 — не заполнено. */
|
||||
TotalAmount: number;
|
||||
VatAmount: number;
|
||||
Currency: string;
|
||||
PayAmount: number;
|
||||
DeliveryTime: string;
|
||||
Comment: string;
|
||||
|
||||
// --- Счёт ---
|
||||
InvoiceFileName: string;
|
||||
/** Адрес файла в библиотеке счетов (ServerRelativeUrl). */
|
||||
InvoiceUrl: string;
|
||||
InvoiceNumber: string;
|
||||
/** Поставщик, как он назван в самом счёте, — сверяется с получателем из СТ. */
|
||||
InvoiceSupplierName: string;
|
||||
InvoiceSupplierBin: string;
|
||||
|
||||
// --- Табличные части ---
|
||||
PreviousPayments: IPreviousPayment[];
|
||||
Positions: IPositionRow[];
|
||||
}
|
||||
|
||||
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
||||
|
||||
/** Пропсы каждой секции формы. */
|
||||
export interface ISectionProps {
|
||||
data: IFormData;
|
||||
set: SetField;
|
||||
context: WebPartContext;
|
||||
/** Подставить сразу несколько полей (выбор основания меняет их пачкой). */
|
||||
patch: (changes: Partial<IFormData>) => void;
|
||||
printOrgIndex?: number;
|
||||
onRequesterChange?: (login: string) => void;
|
||||
}
|
||||
|
||||
export const EMPTY_DATA: IFormData = {
|
||||
RequesterEmail: '',
|
||||
RequesterName: '',
|
||||
RequesterJobTitle: '',
|
||||
Organization: '',
|
||||
Department: '',
|
||||
DepartmentId: '',
|
||||
RequestDate: '',
|
||||
BasisKind: 'ct',
|
||||
BasisKey: '',
|
||||
BasisTitle: '',
|
||||
ProjectName: '',
|
||||
ProjectId: '',
|
||||
ProjectManager: '',
|
||||
CompanyId: '',
|
||||
CompanyName: '',
|
||||
BankAccountId: '',
|
||||
BankAccountName: '',
|
||||
RecipientName: '',
|
||||
RecipientBin: '',
|
||||
RecipientAccount: '',
|
||||
Section: '',
|
||||
WorkKind: '',
|
||||
ContractId: '',
|
||||
ContractName: '',
|
||||
CashFlowGroupId: '',
|
||||
CashFlowGroupName: '',
|
||||
CashFlowItemId: '',
|
||||
CashFlowItemName: '',
|
||||
PaymentPurpose: '',
|
||||
DeliveryTerms: '',
|
||||
PaymentTerms: '',
|
||||
TotalAmount: 0,
|
||||
VatAmount: 0,
|
||||
Currency: '',
|
||||
PayAmount: 0,
|
||||
DeliveryTime: '',
|
||||
Comment: '',
|
||||
InvoiceFileName: '',
|
||||
InvoiceUrl: '',
|
||||
InvoiceNumber: '',
|
||||
InvoiceSupplierName: '',
|
||||
InvoiceSupplierBin: '',
|
||||
PreviousPayments: [],
|
||||
Positions: []
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { buildRows, hasPositionIssues, IInvoiceItem } from './positions';
|
||||
import { IBasisPosition } from './services/basis.types';
|
||||
|
||||
const base = (name: string, qty: number, unit = 'шт'): IBasisPosition => ({
|
||||
code: `K-${name}`,
|
||||
name,
|
||||
unit,
|
||||
qty,
|
||||
supplierName: '',
|
||||
supplierQty: 0,
|
||||
supplierPrice: 0
|
||||
});
|
||||
|
||||
const item = (name: string, quantity: number, unit = 'шт'): IInvoiceItem => ({
|
||||
name,
|
||||
unit,
|
||||
quantity,
|
||||
price: 10,
|
||||
sum: 10 * quantity
|
||||
});
|
||||
|
||||
describe('buildRows', () => {
|
||||
it('совпавшая пара — ok', () => {
|
||||
const rows = buildRows([base('Цемент', 5)], [item('Цемент М400', 5)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].status).toBe('ok');
|
||||
expect(rows[0].invoiceName).toBe('Цемент М400');
|
||||
});
|
||||
|
||||
it('разное количество — diff с пояснением', () => {
|
||||
const rows = buildRows([base('Цемент', 5)], [item('Цемент', 7)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
|
||||
expect(rows[0].status).toBe('diff');
|
||||
expect(rows[0].note).toContain('количество');
|
||||
});
|
||||
|
||||
it('единицы измерения сравниваются без регистра и точек', () => {
|
||||
const rows = buildRows([base('Труба', 2, 'Шт.')], [item('Труба', 2, 'шт')], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
|
||||
expect(rows[0].status).toBe('ok');
|
||||
});
|
||||
|
||||
it('позиция, которую ИИ не упомянул, не пропадает', () => {
|
||||
const rows = buildRows([base('А', 1), base('Б', 1)], [item('А', 1), item('В', 1)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
|
||||
expect(rows.map((r) => r.status)).toEqual(['ok', 'missing', 'extra']);
|
||||
});
|
||||
|
||||
it('пары с неверными индексами и повторы отбрасываются', () => {
|
||||
const rows = buildRows(
|
||||
[base('А', 1)],
|
||||
[item('А', 1), item('Б', 1)],
|
||||
[
|
||||
{ baseIndex: 0, invoiceIndex: 0, note: '' },
|
||||
{ baseIndex: 0, invoiceIndex: 1, note: '' },
|
||||
{ baseIndex: 9, invoiceIndex: 1, note: '' }
|
||||
]
|
||||
);
|
||||
expect(rows.map((r) => r.status)).toEqual(['ok', 'extra']);
|
||||
});
|
||||
|
||||
it('hasPositionIssues видит любое расхождение', () => {
|
||||
expect(hasPositionIssues(buildRows([base('А', 1)], [], []))).toBe(true);
|
||||
expect(hasPositionIssues(buildRows([base('А', 1)], [item('А', 1)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { IPositionRow } from './model';
|
||||
import { IBasisPosition } from './services/basis.types';
|
||||
|
||||
/** Позиция, распознанная в счёте. */
|
||||
export interface IInvoiceItem {
|
||||
name: string;
|
||||
unit: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
sum: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пара, которую вернула ИИ: какой позиции основания (baseIndex) соответствует
|
||||
* какая позиция счёта (invoiceIndex). −1 с одной из сторон — пары нет.
|
||||
*/
|
||||
export interface IPositionPair {
|
||||
baseIndex: number;
|
||||
invoiceIndex: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
const QTY_EPSILON = 0.0001;
|
||||
|
||||
function emptyInvoicePart(): Pick<IPositionRow, 'invoiceName' | 'invoiceUnit' | 'invoiceQty' | 'invoicePrice' | 'invoiceSum'> {
|
||||
return { invoiceName: '', invoiceUnit: '', invoiceQty: 0, invoicePrice: 0, invoiceSum: 0 };
|
||||
}
|
||||
|
||||
function invoicePart(item: IInvoiceItem): ReturnType<typeof emptyInvoicePart> {
|
||||
return {
|
||||
invoiceName: item.name,
|
||||
invoiceUnit: item.unit,
|
||||
invoiceQty: item.quantity,
|
||||
invoicePrice: item.price,
|
||||
invoiceSum: item.sum
|
||||
};
|
||||
}
|
||||
|
||||
/** Ед. изм. для сравнения: «шт.», «Шт» и «шт» — одно и то же. */
|
||||
function normalizeUnit(unit: string): string {
|
||||
return unit.toLowerCase().replace(/[.\s]/g, '');
|
||||
}
|
||||
|
||||
function basisPart(position: IBasisPosition): Pick<IPositionRow, 'code' | 'name' | 'unit' | 'qty' | 'supplierName' | 'supplierQty' | 'supplierPrice'> {
|
||||
return {
|
||||
code: position.code,
|
||||
name: position.name,
|
||||
unit: position.unit,
|
||||
qty: position.qty,
|
||||
supplierName: position.supplierName,
|
||||
supplierQty: position.supplierQty,
|
||||
supplierPrice: position.supplierPrice
|
||||
};
|
||||
}
|
||||
|
||||
/** Строки таблицы до загрузки счёта: только основание, сверять пока не с чем. */
|
||||
export function rowsFromBasis(base: IBasisPosition[]): IPositionRow[] {
|
||||
return base.map((position) => ({
|
||||
...basisPart(position),
|
||||
...emptyInvoicePart(),
|
||||
status: 'ok',
|
||||
note: ''
|
||||
}));
|
||||
}
|
||||
|
||||
/** Расхождения парной строки: ед. изм. и количество сравниваем сами, не полагаясь на ИИ. */
|
||||
function diffNote(position: IBasisPosition, item: IInvoiceItem, aiNote: string): string {
|
||||
const notes: string[] = [];
|
||||
if (position.unit && item.unit && normalizeUnit(position.unit) !== normalizeUnit(item.unit)) {
|
||||
notes.push(`ед. изм.: ${position.unit} → ${item.unit}`);
|
||||
}
|
||||
if (Math.abs(position.qty - item.quantity) > QTY_EPSILON) {
|
||||
notes.push(`количество: ${position.qty} → ${item.quantity}`);
|
||||
}
|
||||
// Смысловое отличие названия (другая марка, размер) видит только ИИ — его пометка сама по себе расхождение.
|
||||
if (aiNote) notes.push(aiNote);
|
||||
return notes.join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Собрать таблицу сверки из ответа ИИ. Пары с неверными индексами и повторы
|
||||
* отбрасываются, а всё, что ИИ не упомянул, попадает в таблицу как «нет в счёте» /
|
||||
* «нет в основании»: ни одна позиция не должна пропасть молча.
|
||||
*/
|
||||
export function buildRows(base: IBasisPosition[], invoice: IInvoiceItem[], pairs: IPositionPair[]): IPositionRow[] {
|
||||
const invoiceByBase = new Map<number, { index: number; note: string }>();
|
||||
const usedInvoice = new Set<number>();
|
||||
|
||||
pairs.forEach((pair) => {
|
||||
const validBase = pair.baseIndex >= 0 && pair.baseIndex < base.length;
|
||||
const validInvoice = pair.invoiceIndex >= 0 && pair.invoiceIndex < invoice.length;
|
||||
if (!validBase || !validInvoice) return;
|
||||
if (invoiceByBase.has(pair.baseIndex) || usedInvoice.has(pair.invoiceIndex)) return;
|
||||
invoiceByBase.set(pair.baseIndex, { index: pair.invoiceIndex, note: pair.note });
|
||||
usedInvoice.add(pair.invoiceIndex);
|
||||
});
|
||||
|
||||
const rows: IPositionRow[] = base.map((position, baseIndex) => {
|
||||
const match = invoiceByBase.get(baseIndex);
|
||||
if (!match) {
|
||||
return { ...basisPart(position), ...emptyInvoicePart(), status: 'missing', note: 'Нет в счёте' };
|
||||
}
|
||||
const item = invoice[match.index];
|
||||
const note = diffNote(position, item, match.note);
|
||||
return { ...basisPart(position), ...invoicePart(item), status: note ? 'diff' : 'ok', note };
|
||||
});
|
||||
|
||||
invoice.forEach((item, index) => {
|
||||
if (usedInvoice.has(index)) return;
|
||||
rows.push({
|
||||
code: '',
|
||||
name: '',
|
||||
unit: '',
|
||||
qty: 0,
|
||||
supplierName: '',
|
||||
supplierQty: 0,
|
||||
supplierPrice: 0,
|
||||
...invoicePart(item),
|
||||
status: 'extra',
|
||||
note: 'Нет в СТ / потребности'
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Есть ли в таблице хоть одно расхождение со счётом. */
|
||||
export function hasPositionIssues(rows: IPositionRow[]): boolean {
|
||||
return rows.some((row) => row.status !== 'ok');
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"dataListTitle": "Заявка на оплату — данные",
|
||||
"columns": [
|
||||
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
|
||||
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||
|
||||
{ "internalName": "RequesterEmail", "title": "Заявитель (email)", "type": "Text" },
|
||||
{ "internalName": "RequesterName", "title": "Заявитель (Ф.И.О.)", "type": "Text" },
|
||||
{ "internalName": "RequesterJobTitle", "title": "Должность заявителя", "type": "Text" },
|
||||
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
||||
{ "internalName": "Department", "title": "Подразделение", "type": "Text" },
|
||||
{ "internalName": "DepartmentId", "title": "Id подразделения (справочник)", "type": "Text" },
|
||||
{ "internalName": "RequestDate", "title": "Дата заявки", "type": "DateTime", "dateOnly": true },
|
||||
|
||||
{ "internalName": "BasisKind", "title": "Основание (ct / need)", "type": "Text" },
|
||||
{ "internalName": "BasisKey", "title": "Ключ основания (ct:Id / need:oid)", "type": "Text" },
|
||||
{ "internalName": "BasisTitle", "title": "Основание", "type": "Text" },
|
||||
{ "internalName": "ProjectName", "title": "Проект", "type": "Text" },
|
||||
{ "internalName": "ProjectId", "title": "Id проекта", "type": "Text" },
|
||||
{ "internalName": "ProjectManager", "title": "Руководитель проекта (email)", "type": "Text" },
|
||||
{ "internalName": "CompanyId", "title": "Id компании", "type": "Text" },
|
||||
{ "internalName": "CompanyName", "title": "Компания", "type": "Text" },
|
||||
|
||||
{ "internalName": "BankAccountId", "title": "Id банковского счёта (1С)", "type": "Text" },
|
||||
{ "internalName": "BankAccountName", "title": "Банковский счёт", "type": "Text" },
|
||||
{ "internalName": "RecipientName", "title": "Получатель", "type": "Text" },
|
||||
{ "internalName": "RecipientBin", "title": "БИН получателя", "type": "Text" },
|
||||
{ "internalName": "RecipientAccount", "title": "Счёт получателя", "type": "Text" },
|
||||
{ "internalName": "Section", "title": "Раздел", "type": "Text" },
|
||||
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
|
||||
{ "internalName": "ContractId", "title": "Id договора (1С)", "type": "Text" },
|
||||
{ "internalName": "ContractName", "title": "Договор", "type": "Text" },
|
||||
{ "internalName": "CashFlowGroupId", "title": "Id группы статьи ДДС", "type": "Text" },
|
||||
{ "internalName": "CashFlowGroupName", "title": "Группа статьи ДДС", "type": "Text" },
|
||||
{ "internalName": "CashFlowItemId", "title": "Id статьи ДДС", "type": "Text" },
|
||||
{ "internalName": "CashFlowItemName", "title": "Статья ДДС", "type": "Text" },
|
||||
{ "internalName": "PaymentPurpose", "title": "Назначение платежа", "type": "Note" },
|
||||
{ "internalName": "DeliveryTerms", "title": "Условия доставки", "type": "Note" },
|
||||
{ "internalName": "PaymentTerms", "title": "Условия оплаты", "type": "Note" },
|
||||
|
||||
{ "internalName": "TotalAmount", "title": "Общая сумма", "type": "Number" },
|
||||
{ "internalName": "VatAmount", "title": "НДС", "type": "Number" },
|
||||
{ "internalName": "Currency", "title": "Валюта", "type": "Text" },
|
||||
{ "internalName": "PayAmount", "title": "Сумма оплаты", "type": "Number" },
|
||||
{ "internalName": "DeliveryTime", "title": "Общий срок поставки", "type": "Text" },
|
||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||
|
||||
{ "internalName": "InvoiceFileName", "title": "Файл счёта", "type": "Text" },
|
||||
{ "internalName": "InvoiceUrl", "title": "Адрес файла счёта", "type": "Text" },
|
||||
{ "internalName": "InvoiceNumber", "title": "Номер счёта", "type": "Text" },
|
||||
{ "internalName": "InvoiceSupplierName", "title": "Поставщик по счёту", "type": "Text" },
|
||||
{ "internalName": "InvoiceSupplierBin", "title": "БИН поставщика по счёту", "type": "Text" },
|
||||
|
||||
{ "internalName": "PreviousPaymentsJson", "title": "Ранее поданные оплаты (JSON)", "type": "Note" },
|
||||
{ "internalName": "PositionsJson", "title": "Позиции: СТ, поставщик, счёт (JSON)", "type": "Note" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import { TextInput } from '../../../shared/ui';
|
||||
import { formatAmountInput, parseAmount } from '../amounts';
|
||||
|
||||
interface IAmountInputProps {
|
||||
id?: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
const toText = (value: number): string => (value ? formatAmountInput(String(value)) : '');
|
||||
|
||||
/**
|
||||
* Поле суммы с десятичной частью и разрядами («1 234 567,50»). Хранит значение числом, но пока пользователь
|
||||
* печатает, держит собственный текст: иначе «12,» превращалось бы в «12» на
|
||||
* каждом нажатии и дробную часть ввести было бы нельзя.
|
||||
*/
|
||||
export const AmountInput: React.FC<IAmountInputProps> = ({ id, value, onChange }) => {
|
||||
const [text, setText] = React.useState<string>(toText(value));
|
||||
|
||||
// Значение изменили снаружи (подставил счёт, загрузилась заявка) — показываем его.
|
||||
React.useEffect(() => {
|
||||
setText((current) => (parseAmount(current) === value ? current : toText(value)));
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
id={id}
|
||||
value={text}
|
||||
onChange={(next) => {
|
||||
const formatted = formatAmountInput(next);
|
||||
setText(formatted);
|
||||
onChange(parseAmount(formatted));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import * as React from 'react';
|
||||
import styles from './PaymentSections.module.scss';
|
||||
import { FormField, TextInput, TextArea } from '../../../shared/ui';
|
||||
import { formatAmount, overLimitAmount, previousPaymentsTotal } from '../amounts';
|
||||
import { IPreviousPayment, ISectionProps } from '../model';
|
||||
import { AmountInput } from './AmountInput';
|
||||
import { RequestDrawer } from './RequestDrawer';
|
||||
|
||||
/**
|
||||
* Суммы платежа. Сумма всех оплат по СТ (прежние заявки + эта) не может превышать
|
||||
* общую сумму СТ — при превышении сумма оплаты подсвечивается, отправку это не блокирует.
|
||||
*/
|
||||
export const AmountsSection: React.FC<ISectionProps> = ({ data, set, context }) => {
|
||||
// Прежняя заявка, открытая в панели справа; undefined — панель закрыта.
|
||||
const [opened, setOpened] = React.useState<IPreviousPayment | undefined>(undefined);
|
||||
const close = React.useCallback(() => setOpened(undefined), []);
|
||||
const over = overLimitAmount(data);
|
||||
const previousTotal = previousPaymentsTotal(data.PreviousPayments);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField label="Общая сумма" htmlFor="TotalAmount">
|
||||
<AmountInput id="TotalAmount" value={data.TotalAmount} onChange={(v) => set('TotalAmount', v)} />
|
||||
</FormField>
|
||||
<FormField label="Валюта" htmlFor="Currency">
|
||||
<TextInput id="Currency" value={data.Currency} onChange={(v) => set('Currency', v.toUpperCase())} placeholder="KZT" />
|
||||
</FormField>
|
||||
<FormField label="НДС" htmlFor="VatAmount">
|
||||
<AmountInput id="VatAmount" value={data.VatAmount} onChange={(v) => set('VatAmount', v)} />
|
||||
</FormField>
|
||||
<FormField label="Сумма оплаты" htmlFor="PayAmount" required>
|
||||
<div className={over > 0 ? styles.warnBox : undefined}>
|
||||
<AmountInput id="PayAmount" value={data.PayAmount} onChange={(v) => set('PayAmount', v)} />
|
||||
{over > 0 && (
|
||||
<div className={styles.warnText}>
|
||||
Все оплаты по основанию превышают общую сумму на {formatAmount(over)} {data.Currency}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Общий срок поставки" htmlFor="DeliveryTime">
|
||||
<TextInput id="DeliveryTime" value={data.DeliveryTime} onChange={(v) => set('DeliveryTime', v)} />
|
||||
</FormField>
|
||||
<FormField label="Комментарий" htmlFor="Comment" full>
|
||||
<TextArea id="Comment" value={data.Comment} onChange={(v) => set('Comment', v)} rows={2} />
|
||||
</FormField>
|
||||
|
||||
<h4 className={styles.fullRow}>Заявки на оплату по этому основанию</h4>
|
||||
{data.PreviousPayments.length === 0 ? (
|
||||
<div className={`${styles.empty} ${styles.fullRow}`}>Ранее заявок на оплату по этому основанию не было.</div>
|
||||
) : (
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Номер заявки на оплату</th>
|
||||
<th>Статус</th>
|
||||
<th className={styles.num}>Сумма оплаты</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.PreviousPayments.map((p) => (
|
||||
<tr key={p.code}>
|
||||
<td>
|
||||
{p.id ? (
|
||||
<button type="button" className={styles.link} onClick={() => setOpened(p)}>
|
||||
{p.code}
|
||||
</button>
|
||||
) : (
|
||||
p.code
|
||||
)}
|
||||
</td>
|
||||
<td>{p.status || ''}</td>
|
||||
<td className={styles.num}>{formatAmount(p.amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={2}>Итого ранее</td>
|
||||
<td className={styles.num}>{formatAmount(previousTotal)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{opened && opened.id && (
|
||||
<RequestDrawer context={context} itemId={opened.id} listId={opened.listId || ''} code={opened.code} onClose={close} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { IDictionaryItem, ItemPicker } from '../../../shared/dictionary';
|
||||
import { friendlyErrorMessage } from '../../../shared/diagnostics';
|
||||
import { BasisKind } from '../model';
|
||||
import { IBasisOption } from '../services/basis.types';
|
||||
import { listApprovedCts } from '../services/ctBasis.service';
|
||||
import { listNeeds } from '../services/needBasis.service';
|
||||
|
||||
interface IBasisPickerProps {
|
||||
context: WebPartContext;
|
||||
kind: BasisKind;
|
||||
/** Подпись выбранного основания. */
|
||||
value: string;
|
||||
onPick: (id: string) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function toPickerItem(option: IBasisOption): IDictionaryItem {
|
||||
return { id: option.id, label: option.title, note: option.note };
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск основания: согласованные СТ либо потребности (по выбранному виду основания).
|
||||
* Выбор запускает загрузку СТ/потребности целиком (см. useBasisSelection), поэтому
|
||||
* ручной ввод текста основанием не считается — onPick зовётся только с id записи.
|
||||
*/
|
||||
export const BasisPicker: React.FC<IBasisPickerProps> = ({ context, kind, value, onPick, readOnly }) => {
|
||||
const [items, setItems] = React.useState<IDictionaryItem[]>([]);
|
||||
const [error, setError] = React.useState<string>('');
|
||||
// null — показываем выбранное основание; строка — пользователь печатает запрос (в т.ч. пустой).
|
||||
const [query, setQuery] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (readOnly) return undefined;
|
||||
let alive = true;
|
||||
setError('');
|
||||
setItems([]);
|
||||
(kind === 'ct' ? listApprovedCts(context) : listNeeds(context))
|
||||
.then((options) => {
|
||||
if (alive) setItems(options.map(toPickerItem));
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setError(`Не удалось загрузить список: ${friendlyErrorMessage(e)}`);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [context, kind, readOnly]);
|
||||
|
||||
return (
|
||||
<ItemPicker
|
||||
id="BasisTitle"
|
||||
items={items}
|
||||
value={query ?? value}
|
||||
readOnly={readOnly}
|
||||
placeholder={kind === 'ct' ? 'Найдите согласованную СТ по номеру или проекту…' : 'Найдите потребность по номеру или проекту…'}
|
||||
hint={error}
|
||||
onChange={(label, id) => {
|
||||
setQuery(id ? null : label);
|
||||
if (id) onPick(id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as React from 'react';
|
||||
import { Alert, FormField, Select, ISelectOption } from '../../../shared/ui';
|
||||
import styles from './PaymentSections.module.scss';
|
||||
import { ICompany, ProjectPicker, getCompanies } from '../../../shared/dictionary';
|
||||
import { BASIS_OPTIONS, BasisKind, ISectionProps } from '../model';
|
||||
import { IBasisSelection } from '../useBasisSelection';
|
||||
import { BasisPicker } from './BasisPicker';
|
||||
|
||||
interface IBasisSectionProps extends ISectionProps {
|
||||
basis: IBasisSelection;
|
||||
}
|
||||
|
||||
const KIND_OPTIONS: ISelectOption[] = BASIS_OPTIONS.map((o) => ({ value: o.value, label: o.label }));
|
||||
|
||||
/**
|
||||
* Основание оплаты: согласованная СТ либо потребность. Выбор основания подставляет
|
||||
* проект, раздел, вид работ, условия, суммы и позиции (см. basisPatch) — всё это
|
||||
* остаётся редактируемым. Проект определяет маршрут согласования.
|
||||
*/
|
||||
export const BasisSection: React.FC<IBasisSectionProps> = ({ data, patch, context, basis }) => {
|
||||
const [companies, setCompanies] = React.useState<ICompany[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
getCompanies(context)
|
||||
.then((list) => {
|
||||
if (alive) setCompanies(list);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [context]);
|
||||
|
||||
const companyOptions: ISelectOption[] = companies.map((c) => ({ value: c.Id, label: c.Name }));
|
||||
// Компания из заявки может отсутствовать в справочнике (его не удалось загрузить) — показываем её как есть.
|
||||
if (data.CompanyId && !companies.some((c) => c.Id === data.CompanyId)) {
|
||||
companyOptions.push({ value: data.CompanyId, label: data.CompanyName });
|
||||
}
|
||||
|
||||
const handleKindChange = (value: string): void => {
|
||||
patch({ BasisKind: value as BasisKind, BasisKey: '', BasisTitle: '', PreviousPayments: [], Positions: [] });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField label="Основание" htmlFor="BasisKind" required>
|
||||
<Select id="BasisKind" value={data.BasisKind} onChange={handleKindChange} options={KIND_OPTIONS} />
|
||||
</FormField>
|
||||
<FormField label={data.BasisKind === 'ct' ? 'Согласованная СТ' : 'Потребность'} htmlFor="BasisTitle" required>
|
||||
<BasisPicker
|
||||
context={context}
|
||||
kind={data.BasisKind}
|
||||
value={data.BasisTitle}
|
||||
onPick={(id) => {
|
||||
basis.select(data.BasisKind, id).catch(() => undefined);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Проект" htmlFor="ProjectName" required>
|
||||
<ProjectPicker
|
||||
id="ProjectName"
|
||||
context={context}
|
||||
value={data.ProjectName}
|
||||
placeholder="Начните вводить название проекта…"
|
||||
onChange={(name, id, project) => {
|
||||
patch({ ProjectName: name, ProjectId: id, ProjectManager: project ? project.projectManager || '' : '' });
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Компания" htmlFor="CompanyId" required>
|
||||
<Select
|
||||
id="CompanyId"
|
||||
value={data.CompanyId}
|
||||
onChange={(id) => {
|
||||
const company = companies.filter((c) => c.Id === id)[0];
|
||||
patch({ CompanyId: id, CompanyName: company ? company.Name : '' });
|
||||
}}
|
||||
options={companyOptions}
|
||||
placeholder="Выберите компанию…"
|
||||
/>
|
||||
</FormField>
|
||||
{basis.loading && <div className={styles.fullRow}><Alert variant="info">Загружаем данные основания…</Alert></div>}
|
||||
{basis.error && <div className={styles.fullRow}><Alert variant="err">{basis.error}</Alert></div>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
|
||||
import { DepartmentPicker } from '../../../shared/dictionary';
|
||||
import { ISectionProps } from '../model';
|
||||
|
||||
/** Шапка заявки: кто подаёт (по умолчанию текущий пользователь), должность, подразделение, дата. */
|
||||
export const HeaderSection: React.FC<ISectionProps> = ({ data, set, context, printOrgIndex, onRequesterChange }) => (
|
||||
<FormGrid>
|
||||
<FormField label="Заявитель" htmlFor="RequesterEmail" required full>
|
||||
<PeopleField
|
||||
context={context}
|
||||
value={data.RequesterEmail}
|
||||
onChange={onRequesterChange ?? ((v) => set('RequesterEmail', v))}
|
||||
placeholder="Начните вводить Ф.И.О. заявителя…"
|
||||
/>
|
||||
</FormField>
|
||||
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
|
||||
|
||||
<FormField label="Должность заявителя" htmlFor="RequesterJobTitle">
|
||||
<TextInput id="RequesterJobTitle" value={data.RequesterJobTitle} onChange={(v) => set('RequesterJobTitle', v)} />
|
||||
</FormField>
|
||||
<FormField label="Подразделение" htmlFor="Department">
|
||||
<DepartmentPicker
|
||||
id="Department"
|
||||
context={context}
|
||||
value={data.Department}
|
||||
onChange={(name, id) => {
|
||||
set('Department', name);
|
||||
set('DepartmentId', id);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Дата заявки" htmlFor="RequestDate">
|
||||
<TextInput id="RequestDate" type="date" value={data.RequestDate} onChange={(v) => set('RequestDate', v)} />
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as React from 'react';
|
||||
import styles from './PaymentSections.module.scss';
|
||||
import { Alert, Button, Icon, useFormReadOnly } from '../../../shared/ui';
|
||||
import { DATA_SITE_URL } from '../../../shared/config';
|
||||
import { IFormData } from '../model';
|
||||
import { INVOICE_STAGE_TEXT, IInvoiceFlow } from '../useInvoiceFlow';
|
||||
|
||||
interface IInvoiceSectionProps {
|
||||
data: IFormData;
|
||||
invoice: IInvoiceFlow;
|
||||
}
|
||||
|
||||
/** Ссылка на файл счёта: в данных лежит серверный относительный адрес. */
|
||||
function invoiceHref(serverRelativeUrl: string): string {
|
||||
return new URL(serverRelativeUrl, DATA_SITE_URL).href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Вложение — счёт на оплату (PDF). Файл сохраняется в библиотеку и уходит на OCR;
|
||||
* ИИ достаёт из текста реквизиты, суммы и позиции и подставляет их в форму.
|
||||
* Если распознать не вышло, счёт всё равно остаётся приложенным — поля заполняются руками.
|
||||
*/
|
||||
export const InvoiceSection: React.FC<IInvoiceSectionProps> = ({ data, invoice }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const busy = invoice.stage !== 'idle';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.invoiceRow}>
|
||||
{data.InvoiceFileName ? (
|
||||
<span>
|
||||
<Icon name="document" size="sm" />{' '}
|
||||
{data.InvoiceUrl ? (
|
||||
<a href={invoiceHref(data.InvoiceUrl)} target="_blank" rel="noreferrer">
|
||||
{data.InvoiceFileName}
|
||||
</a>
|
||||
) : (
|
||||
data.InvoiceFileName
|
||||
)}
|
||||
{data.InvoiceNumber && ` — счёт № ${data.InvoiceNumber}`}
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.hint}>Счёт ещё не приложен.</span>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={styles.fileInput}
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (file) invoice.attach(file).catch(() => undefined);
|
||||
}}
|
||||
/>
|
||||
<Button variant="secondary" disabled={busy} onClick={() => inputRef.current?.click()}>
|
||||
<Icon name="upload" size="sm" />
|
||||
{data.InvoiceFileName ? 'Заменить счёт' : 'Приложить счёт (PDF)'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{busy && <span className={styles.stage}>{INVOICE_STAGE_TEXT[invoice.stage]}</span>}
|
||||
</div>
|
||||
{invoice.error && <Alert variant="warn">{invoice.error}</Alert>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import * as React from 'react';
|
||||
import styles from './PaymentSections.module.scss';
|
||||
import { FormField, TextInput, TextArea } from '../../../shared/ui';
|
||||
import {
|
||||
DictionaryPicker,
|
||||
getCashFlowGroups,
|
||||
getCashFlowItems,
|
||||
searchBankAccounts,
|
||||
searchContracts
|
||||
} from '../../../shared/dictionary';
|
||||
import { recipientMatchesInvoice } from '../amounts';
|
||||
import { ISectionProps } from '../model';
|
||||
|
||||
/**
|
||||
* Реквизиты платежа. Банковский счёт, договор и статьи ДДС — из справочников 1С
|
||||
* (счета фильтруются по компании и проекту, договоры ищутся по названию, коду и
|
||||
* контрагенту — фильтра по компании у API договоров нет). Получатель — победитель
|
||||
* СТ; если в самом счёте указан другой контрагент, поле подсвечивается.
|
||||
*/
|
||||
export const PaymentSection: React.FC<ISectionProps> = ({ data, set, patch, context }) => {
|
||||
const recipientMatches = recipientMatchesInvoice(
|
||||
{ name: data.RecipientName, bin: data.RecipientBin },
|
||||
{ name: data.InvoiceSupplierName, bin: data.InvoiceSupplierBin }
|
||||
);
|
||||
const accountFilter = { companyId: data.CompanyId, projectId: data.ProjectId };
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField label="Банковский счёт" htmlFor="BankAccountName" required>
|
||||
<DictionaryPicker
|
||||
id="BankAccountName"
|
||||
load={(search) => searchBankAccounts(context, accountFilter, search)}
|
||||
loadKey={`${data.CompanyId}|${data.ProjectId}`}
|
||||
serverSearch
|
||||
value={data.BankAccountName}
|
||||
placeholder="Счёт компании в 1С…"
|
||||
onChange={(label, id) => patch({ BankAccountName: label, BankAccountId: id })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Договор" htmlFor="ContractName">
|
||||
<DictionaryPicker
|
||||
id="ContractName"
|
||||
load={(search) => searchContracts(context, search)}
|
||||
loadKey="contracts"
|
||||
serverSearch
|
||||
value={data.ContractName}
|
||||
placeholder="Название, код договора или контрагент…"
|
||||
onChange={(label, id) => patch({ ContractName: label, ContractId: id })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Получатель" htmlFor="RecipientName" required>
|
||||
<div className={recipientMatches ? undefined : styles.warnBox}>
|
||||
<TextInput id="RecipientName" value={data.RecipientName} onChange={(v) => set('RecipientName', v)} />
|
||||
{!recipientMatches && (
|
||||
<div className={styles.warnText}>
|
||||
В счёте указан другой контрагент: {data.InvoiceSupplierName || '—'}
|
||||
{data.InvoiceSupplierBin ? ` (БИН ${data.InvoiceSupplierBin})` : ''}. Проверьте получателя.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="Счёт получателя (IBAN)" htmlFor="RecipientAccount" required>
|
||||
<TextInput id="RecipientAccount" value={data.RecipientAccount} onChange={(v) => set('RecipientAccount', v)} />
|
||||
</FormField>
|
||||
|
||||
<FormField label="Раздел" htmlFor="Section">
|
||||
<TextInput id="Section" value={data.Section} onChange={(v) => set('Section', v)} />
|
||||
</FormField>
|
||||
<FormField label="Вид работ" htmlFor="WorkKind">
|
||||
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
|
||||
</FormField>
|
||||
|
||||
<FormField label="Группа статьи ДДС" htmlFor="CashFlowGroupName" required>
|
||||
<DictionaryPicker
|
||||
id="CashFlowGroupName"
|
||||
load={() => getCashFlowGroups(context)}
|
||||
loadKey="cash-flow-groups"
|
||||
value={data.CashFlowGroupName}
|
||||
onChange={(label, id) => patch({ CashFlowGroupName: label, CashFlowGroupId: id, CashFlowItemName: '', CashFlowItemId: '' })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Статья ДДС" htmlFor="CashFlowItemName" required>
|
||||
<DictionaryPicker
|
||||
id="CashFlowItemName"
|
||||
load={() => getCashFlowItems(context, data.CashFlowGroupId)}
|
||||
loadKey={data.CashFlowGroupId}
|
||||
value={data.CashFlowItemName}
|
||||
onChange={(label, id) => patch({ CashFlowItemName: label, CashFlowItemId: id })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Назначение платежа" htmlFor="PaymentPurpose" required full>
|
||||
<TextArea id="PaymentPurpose" value={data.PaymentPurpose} onChange={(v) => set('PaymentPurpose', v)} rows={2} />
|
||||
</FormField>
|
||||
<FormField label="Условия доставки" htmlFor="DeliveryTerms">
|
||||
<TextArea id="DeliveryTerms" value={data.DeliveryTerms} onChange={(v) => set('DeliveryTerms', v)} rows={2} />
|
||||
</FormField>
|
||||
<FormField label="Условия оплаты" htmlFor="PaymentTerms">
|
||||
<TextArea id="PaymentTerms" value={data.PaymentTerms} onChange={(v) => set('PaymentTerms', v)} rows={2} />
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// Подсветка расхождений: поле, значение которого стоит перепроверить, обводится
|
||||
// красным, а под ним — пояснение. Подсветка не блокирует отправку: решает человек.
|
||||
.warnBox {
|
||||
padding: $sp-1;
|
||||
border: 1px solid $red-500;
|
||||
border-radius: $r-sm;
|
||||
background: $red-50;
|
||||
}
|
||||
|
||||
.warnText {
|
||||
margin-top: $sp-1;
|
||||
font-size: $fs-caption;
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: $sp-1;
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.invoiceRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: $sp-4;
|
||||
}
|
||||
|
||||
.fileInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stage {
|
||||
font-size: $fs-sm;
|
||||
color: $navy-700;
|
||||
}
|
||||
|
||||
.tableWrap {
|
||||
// Тело секции — flex/grid из ui-kit: без явной ширины блок ужимается по содержимому.
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
grid-column: 1 / -1;
|
||||
overflow-x: auto;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-sm;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: $fs-sm;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
background: $gray-50;
|
||||
font-size: $fs-caption;
|
||||
font-weight: 600;
|
||||
color: $gray-700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
font-weight: 600;
|
||||
background: $gray-50;
|
||||
}
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowDiff {
|
||||
background: $gold-50;
|
||||
}
|
||||
|
||||
.rowBad {
|
||||
background: $red-50;
|
||||
}
|
||||
|
||||
.rowOk {
|
||||
color: $green-600;
|
||||
}
|
||||
|
||||
.statusBad {
|
||||
color: $red-500;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: $sp-4;
|
||||
color: $gray-500;
|
||||
font-size: $fs-sm;
|
||||
}
|
||||
|
||||
// Тело CollapseSection из ui-kit — сетка в две колонки; всё не-поле растягиваем на обе.
|
||||
.fullRow {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: $navy-700;
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
color: $gold-600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as React from 'react';
|
||||
import styles from './PaymentSections.module.scss';
|
||||
import { Alert } from '../../../shared/ui';
|
||||
import { formatAmount } from '../amounts';
|
||||
import { IPositionRow, RowStatus } from '../model';
|
||||
import { hasPositionIssues } from '../positions';
|
||||
|
||||
interface IPositionsSectionProps {
|
||||
rows: IPositionRow[];
|
||||
/** Счёт приложен и сверка выполнялась — только тогда статусы строк что-то значат. */
|
||||
compared: boolean;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<RowStatus, string> = {
|
||||
ok: 'Совпадает',
|
||||
diff: 'Расхождение',
|
||||
missing: 'Нет в счёте',
|
||||
extra: 'Нет в СТ'
|
||||
};
|
||||
|
||||
function rowClass(row: IPositionRow, compared: boolean): string | undefined {
|
||||
if (!compared) return undefined;
|
||||
if (row.status === 'diff') return styles.rowDiff;
|
||||
if (row.status === 'missing' || row.status === 'extra') return styles.rowBad;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cell = (value: number): string => (value ? formatAmount(value) : '');
|
||||
|
||||
/**
|
||||
* Позиции: из СТ/потребности (код, наименование, ед. изм.), позиции поставщика и
|
||||
* позиции из счёта. Строки с расхождениями подсвечены; сверку выполняет ИИ при
|
||||
* загрузке счёта, количество и единицы сверяются кодом.
|
||||
*/
|
||||
export const PositionsSection: React.FC<IPositionsSectionProps> = ({ rows, compared }) => {
|
||||
if (rows.length === 0) {
|
||||
return <div className={styles.empty}>Позиции появятся после выбора основания.</div>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{compared && hasPositionIssues(rows) && (
|
||||
<Alert variant="warn">Позиции счёта отличаются от СТ/потребности — расхождения подсвечены.</Alert>
|
||||
)}
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Код</th>
|
||||
<th>Наименование (потребность)</th>
|
||||
<th>Ед. изм.</th>
|
||||
<th className={styles.num}>Кол-во</th>
|
||||
<th>Позиция поставщика</th>
|
||||
<th>Позиция из счёта</th>
|
||||
<th className={styles.num}>Кол-во в счёте</th>
|
||||
<th className={styles.num}>Цена</th>
|
||||
<th className={styles.num}>Сумма</th>
|
||||
{compared && <th>Сверка</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={`${row.code}-${index}`} className={rowClass(row, compared)}>
|
||||
<td>{row.code}</td>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.unit}</td>
|
||||
<td className={styles.num}>{cell(row.qty)}</td>
|
||||
<td>{row.supplierName}</td>
|
||||
<td>{row.invoiceName}</td>
|
||||
<td className={styles.num}>{cell(row.invoiceQty)}</td>
|
||||
<td className={styles.num}>{cell(row.invoicePrice)}</td>
|
||||
<td className={styles.num}>{cell(row.invoiceSum)}</td>
|
||||
{compared && (
|
||||
<td className={row.status === 'ok' ? styles.rowOk : styles.statusBad}>
|
||||
{STATUS_LABEL[row.status]}
|
||||
{row.note && row.status !== 'ok' && <div>{row.note}</div>}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { Drawer } from '../../../components/Drawer/Drawer';
|
||||
import { Spinner } from '@tnsu/ui-kit';
|
||||
import portalStyles from '../../../components/RequestPortal.module.scss';
|
||||
import { FORM_ID } from '../model';
|
||||
|
||||
// Лениво: ApproveFeature через реестр просмотров тянет PaymentRequestView, а тот —
|
||||
// эту же секцию. Статический импорт замкнул бы цикл модулей.
|
||||
const ApproveFeature = React.lazy(() => import(/* webpackChunkName: 'approve-feature' */ '../../../features/approve/ApproveFeature'));
|
||||
|
||||
interface IRequestDrawerProps {
|
||||
context: WebPartContext;
|
||||
itemId: number;
|
||||
/** Партиция, где лежит заявка; '' — текущая. */
|
||||
listId: string;
|
||||
code: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Прежняя заявка на оплату в выезжающей панели — той же, что у таблиц портала.
|
||||
* Открываем её здесь, а не переходом на rpmode=approve: смена маршрута
|
||||
* пересоздала бы открытую форму, и всё введённое пропало бы.
|
||||
*
|
||||
* Через портал в body: форма сама бывает открыта в панели (черновик), а
|
||||
* fixed-слой внутри неё ограничивался бы её рамками. Корневой класс портала
|
||||
* возвращает шрифт и цвет, которые в body не наследуются.
|
||||
*/
|
||||
export const RequestDrawer: React.FC<IRequestDrawerProps> = ({ context, itemId, listId, code, onClose }) =>
|
||||
ReactDOM.createPortal(
|
||||
<div className={portalStyles.requestPortal}>
|
||||
<Drawer title={`Заявка №${code}`} onClose={onClose}>
|
||||
<React.Suspense fallback={<Spinner block label="Загружаем заявку…" />}>
|
||||
<ApproveFeature context={context} formKey={FORM_ID} itemId={itemId} listId={listId || undefined} />
|
||||
</React.Suspense>
|
||||
</Drawer>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { BasisKind } from '../model';
|
||||
|
||||
/** Позиция основания: из СТ (с предложением победителя) либо из потребности. */
|
||||
export interface IBasisPosition {
|
||||
/** Уникальный код позиции потребности; '' — у потребности его нет. */
|
||||
code: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
qty: number;
|
||||
/** Позиция поставщика из его КП; '' / 0 — у потребности КП нет. */
|
||||
supplierName: string;
|
||||
supplierQty: number;
|
||||
supplierPrice: number;
|
||||
}
|
||||
|
||||
/** Всё, что форма берёт из выбранной СТ или потребности. */
|
||||
export interface IBasisInfo {
|
||||
kind: BasisKind;
|
||||
/** «ct:<Id СТ>» / «need:<oid потребности>» — по нему ищем прежние оплаты. */
|
||||
key: string;
|
||||
title: string;
|
||||
projectName: string;
|
||||
/** Раздел и вид работ. */
|
||||
section: string;
|
||||
workKind: string;
|
||||
/** Компания-заказчик, если известна (подбирается по справочнику компаний). */
|
||||
companyHint: string;
|
||||
/** Выигравший поставщик СТ; у потребности пустой. */
|
||||
recipientName: string;
|
||||
recipientBin: string;
|
||||
/** Общая сумма основания и её валюта; 0 — не определена. */
|
||||
total: number;
|
||||
currency: string;
|
||||
deliveryTerms: string;
|
||||
paymentTerms: string;
|
||||
deliveryTime: string;
|
||||
positions: IBasisPosition[];
|
||||
}
|
||||
|
||||
/** Строка выпадающего списка выбора основания. */
|
||||
export interface IBasisOption {
|
||||
/** Id СТ либо oid потребности. */
|
||||
id: string;
|
||||
title: string;
|
||||
/** Пояснение: проект, дата, число позиций. */
|
||||
note: string;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { CT_SITE_URL } from '../../../shared/config';
|
||||
import { spForSite } from '../../../shared/pnp/pnpjs';
|
||||
import { IBasisInfo, IBasisOption, IBasisPosition } from './basis.types';
|
||||
|
||||
// Списки сайта СТ (см. ct-forms-portal, CtService.LISTS) — имена те же.
|
||||
const CT_LISTS = {
|
||||
ct: 'Сравнительная таблица',
|
||||
positions: 'Позиции СТ',
|
||||
offers: 'Оценка поставщиков',
|
||||
suppliers: 'Поставщики',
|
||||
criteria: 'Критерии',
|
||||
rates: 'Курс Валют'
|
||||
};
|
||||
|
||||
/** Значение Choice «Статус» у согласованной СТ. */
|
||||
const CT_STATUS_APPROVED = 'Согласовано';
|
||||
/** Валюта предложений без подстановки «Валюта» — в СТ это тенге. */
|
||||
const DEFAULT_CURRENCY = 'KZT';
|
||||
const MAX_CTS = 500;
|
||||
const MAX_POSITIONS = 500;
|
||||
/** Сколько позиций кладём в один OR-фильтр: длинная строка запроса SharePoint не принимает. */
|
||||
const FILTER_CHUNK = 20;
|
||||
|
||||
interface ICtRow {
|
||||
Id: number;
|
||||
number?: number | string;
|
||||
project?: string;
|
||||
winSupplierId?: number;
|
||||
}
|
||||
|
||||
interface IPositionRow {
|
||||
Id: number;
|
||||
Title?: string;
|
||||
unit?: string;
|
||||
quantity?: number;
|
||||
disciplineCode?: string;
|
||||
SMR?: string;
|
||||
uniqueCode?: string;
|
||||
}
|
||||
|
||||
interface IOfferRow {
|
||||
positionId: number;
|
||||
supplierPosition?: string;
|
||||
quantity?: number;
|
||||
price?: number;
|
||||
summa?: number;
|
||||
summaTenge?: number;
|
||||
valutaId?: number;
|
||||
}
|
||||
|
||||
interface ICriteriaRow {
|
||||
paymentType?: string;
|
||||
deliveryType?: string;
|
||||
deadline?: string;
|
||||
criteriaLabel?: string;
|
||||
}
|
||||
|
||||
function ctWeb(context: WebPartContext): ReturnType<typeof spForSite>['web'] {
|
||||
return spForSite(CT_SITE_URL, context).web;
|
||||
}
|
||||
|
||||
function chunk<T>(items: T[], size: number): T[][] {
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) result.push(items.slice(i, i + size));
|
||||
return result;
|
||||
}
|
||||
|
||||
function unique(values: Array<string | undefined>): string[] {
|
||||
const seen: string[] = [];
|
||||
values.forEach((v) => {
|
||||
const value = (v || '').trim();
|
||||
if (value && seen.indexOf(value) === -1) seen.push(value);
|
||||
});
|
||||
return seen;
|
||||
}
|
||||
|
||||
function ctTitle(row: ICtRow): string {
|
||||
return `СТ №${row.number ?? row.Id}${row.project ? ` — ${row.project}` : ''}`;
|
||||
}
|
||||
|
||||
/** Согласованные СТ для выбора основания, новые сверху. */
|
||||
export async function listApprovedCts(context: WebPartContext): Promise<IBasisOption[]> {
|
||||
const rows = (await ctWeb(context)
|
||||
.lists.getByTitle(CT_LISTS.ct)
|
||||
.items.select('Id', 'number', 'project', 'winSupplierId')
|
||||
.filter(`status eq '${CT_STATUS_APPROVED}'`)
|
||||
.orderBy('Id', false)
|
||||
.top(MAX_CTS)()) as ICtRow[];
|
||||
return rows.map((row) => ({
|
||||
id: String(row.Id),
|
||||
title: ctTitle(row),
|
||||
note: row.winSupplierId ? '' : 'победитель не выбран'
|
||||
}));
|
||||
}
|
||||
|
||||
/** Предложения победителя по позициям СТ (по чанкам — см. FILTER_CHUNK). */
|
||||
async function loadWinnerOffers(
|
||||
context: WebPartContext,
|
||||
supplierId: number,
|
||||
positionIds: number[]
|
||||
): Promise<IOfferRow[]> {
|
||||
const lists = ctWeb(context).lists;
|
||||
const parts = await Promise.all(
|
||||
chunk(positionIds, FILTER_CHUNK).map((ids) =>
|
||||
lists
|
||||
.getByTitle(CT_LISTS.offers)
|
||||
.items.select('Id', 'positionId', 'supplierPosition', 'quantity', 'price', 'summa', 'summaTenge', 'valutaId')
|
||||
.filter(`supplierId eq ${supplierId} and (${ids.map((id) => `positionId eq ${id}`).join(' or ')})`)
|
||||
.top(MAX_POSITIONS)()
|
||||
)
|
||||
);
|
||||
return ([] as IOfferRow[]).concat(...(parts as IOfferRow[][]));
|
||||
}
|
||||
|
||||
/** Код валюты по Id записи «Курс Валют»; пустая подстановка — тенге. */
|
||||
async function loadCurrencyCodes(context: WebPartContext, ids: number[]): Promise<Map<number, string>> {
|
||||
const codes = new Map<number, string>();
|
||||
await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const rate = (await ctWeb(context).lists.getByTitle(CT_LISTS.rates).items.getById(id).select('code')()) as { code?: string };
|
||||
codes.set(id, rate.code || DEFAULT_CURRENCY);
|
||||
})
|
||||
);
|
||||
return codes;
|
||||
}
|
||||
|
||||
/** Условия победителя из «Критериев» СТ: оплата, доставка, срок. */
|
||||
async function loadWinnerTerms(context: WebPartContext, ctId: number, supplierId: number): Promise<ICriteriaRow> {
|
||||
const rows = (await ctWeb(context)
|
||||
.lists.getByTitle(CT_LISTS.criteria)
|
||||
.items.select('Id', 'paymentType', 'deliveryType', 'deadline', 'criteriaLabel')
|
||||
.filter(`CTId eq ${ctId} and supplierId eq ${supplierId}`)
|
||||
.top(MAX_POSITIONS)()) as ICriteriaRow[];
|
||||
// Строки с criteriaLabel — произвольные критерии, а не условия поставки.
|
||||
return rows.filter((r) => !r.criteriaLabel && (r.paymentType || r.deliveryType || r.deadline))[0] || {};
|
||||
}
|
||||
|
||||
function toBasisPosition(position: IPositionRow, offers: IOfferRow[]): IBasisPosition {
|
||||
return {
|
||||
code: position.uniqueCode || '',
|
||||
name: position.Title || '',
|
||||
unit: position.unit || '',
|
||||
qty: Number(position.quantity || 0),
|
||||
supplierName: unique(offers.map((o) => o.supplierPosition)).join(' / '),
|
||||
supplierQty: offers.reduce((sum, o) => sum + Number(o.quantity || 0), 0),
|
||||
supplierPrice: Number(offers[0]?.price || 0)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Общая сумма предложений победителя. Все в одной валюте — суммируем «Сумма» в
|
||||
* этой валюте; смешанные — берём «Сумма в тенге», иначе числа несопоставимы.
|
||||
*/
|
||||
function offersTotal(offers: IOfferRow[], codes: Map<number, string>): { total: number; currency: string } {
|
||||
const currencies = unique(offers.map((o) => (o.valutaId ? codes.get(o.valutaId) : DEFAULT_CURRENCY)));
|
||||
if (currencies.length > 1) {
|
||||
return { total: offers.reduce((s, o) => s + Number(o.summaTenge || 0), 0), currency: DEFAULT_CURRENCY };
|
||||
}
|
||||
return { total: offers.reduce((s, o) => s + Number(o.summa || 0), 0), currency: currencies[0] || DEFAULT_CURRENCY };
|
||||
}
|
||||
|
||||
/** Всё, что форма берёт из согласованной СТ: победитель, позиции, суммы, условия. */
|
||||
export async function loadCtBasis(context: WebPartContext, ctId: string): Promise<IBasisInfo> {
|
||||
const web = ctWeb(context);
|
||||
const id = parseInt(ctId, 10);
|
||||
const [ct, positions] = await Promise.all([
|
||||
web.lists.getByTitle(CT_LISTS.ct).items.getById(id).select('Id', 'number', 'project', 'winSupplierId')() as Promise<ICtRow>,
|
||||
web.lists
|
||||
.getByTitle(CT_LISTS.positions)
|
||||
.items.select('Id', 'Title', 'unit', 'quantity', 'disciplineCode', 'SMR', 'uniqueCode')
|
||||
.filter(`CTId eq ${id}`)
|
||||
.top(MAX_POSITIONS)() as Promise<IPositionRow[]>
|
||||
]);
|
||||
|
||||
const winnerId = ct.winSupplierId || 0;
|
||||
const [winner, offers, terms] = await Promise.all([
|
||||
winnerId
|
||||
? (web.lists.getByTitle(CT_LISTS.suppliers).items.getById(winnerId).select('name', 'bin')() as Promise<{ name?: string; bin?: string }>)
|
||||
: Promise.resolve({} as { name?: string; bin?: string }),
|
||||
winnerId && positions.length > 0 ? loadWinnerOffers(context, winnerId, positions.map((p) => p.Id)) : Promise.resolve([] as IOfferRow[]),
|
||||
winnerId ? loadWinnerTerms(context, id, winnerId) : Promise.resolve({} as ICriteriaRow)
|
||||
]);
|
||||
const currencyIds = unique(offers.map((o) => (o.valutaId ? String(o.valutaId) : ''))).map(Number);
|
||||
const codes = await loadCurrencyCodes(context, currencyIds);
|
||||
const { total, currency } = offersTotal(offers, codes);
|
||||
|
||||
return {
|
||||
kind: 'ct',
|
||||
key: `ct:${ct.Id}`,
|
||||
title: ctTitle(ct),
|
||||
projectName: ct.project || '',
|
||||
section: unique(positions.map((p) => p.disciplineCode)).join(', '),
|
||||
workKind: unique(positions.map((p) => p.SMR)).join(', '),
|
||||
companyHint: '',
|
||||
recipientName: winner.name || '',
|
||||
recipientBin: winner.bin || '',
|
||||
total,
|
||||
currency,
|
||||
deliveryTerms: terms.deliveryType || '',
|
||||
paymentTerms: terms.paymentType || '',
|
||||
deliveryTime: terms.deadline || '',
|
||||
positions: positions.map((p) =>
|
||||
toBasisPosition(
|
||||
p,
|
||||
offers.filter((o) => o.positionId === p.Id)
|
||||
)
|
||||
)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
import '@pnp/sp/items';
|
||||
import '@pnp/sp/folders';
|
||||
import '@pnp/sp/files';
|
||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { APPROVAL_API_CLIENT_ID, DATA_SITE_URL, DICTIONARY_API_BASE_URL } from '../../../shared/config';
|
||||
import { PAYMENT_INVOICES_LIBRARY_TITLE } from '../../../shared/listTitles';
|
||||
import { getSP } from '../../../shared/pnp/pnpjs';
|
||||
import { fileStamp } from '../../../shared/chat/chatFileNames';
|
||||
|
||||
/** Колонка библиотеки, куда OCR кладёт распознанный текст счёта. */
|
||||
const FILE_CONTENT_FIELD = 'file_content';
|
||||
|
||||
export const MAX_INVOICE_SIZE_BYTES = 15 * 1024 * 1024;
|
||||
|
||||
export interface IStoredInvoice {
|
||||
/** Id элемента библиотеки — по нему сервис OCR находит файл. */
|
||||
itemId: number;
|
||||
/** Имя файла, как он назван в библиотеке (с отметкой времени). */
|
||||
storedName: string;
|
||||
serverRelativeUrl: string;
|
||||
}
|
||||
|
||||
/** Проверка файла счёта до загрузки. Возвращает текст ошибки или undefined. */
|
||||
export function validateInvoiceFile(file: File): string | undefined {
|
||||
if (!/\.pdf$/i.test(file.name)) {
|
||||
return 'Счёт должен быть в формате PDF — другие форматы сервис распознавания не читает.';
|
||||
}
|
||||
if (file.size > MAX_INVOICE_SIZE_BYTES) {
|
||||
return `Файл больше ${Math.round(MAX_INVOICE_SIZE_BYTES / (1024 * 1024))} МБ.`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить файл счёта в библиотеку. Делаем это сразу при выборе файла, до
|
||||
* сохранения самой заявки: OCR работает с элементом библиотеки, а не с вложением.
|
||||
* Имя в библиотеке — только латиница и цифры: OCR-сервис получает имя в multipart и
|
||||
* на кириллице теряет расширение («unsupported file type ''»). Исходное имя
|
||||
* хранится в данных заявки (InvoiceFileName), поэтому в библиотеке оно не нужно.
|
||||
* Метка времени и случайный хвост исключают перезапись счёта другой заявки.
|
||||
*/
|
||||
export async function storeInvoice(file: File): Promise<IStoredInvoice> {
|
||||
const storedName = `invoice-${fileStamp(new Date())}-${Math.random().toString(36).slice(2, 8)}.pdf`;
|
||||
const root = getSP().web.lists.getByTitle(PAYMENT_INVOICES_LIBRARY_TITLE).rootFolder;
|
||||
const added = await root.files.addUsingPath(storedName, file);
|
||||
// Id элемента библиотеки нужен OCR, а addUsingPath отдаёт только сведения о файле.
|
||||
const item = (await getSP()
|
||||
.web.getFileByServerRelativePath(added.ServerRelativeUrl)
|
||||
.listItemAllFields.select('Id')()) as { Id: number };
|
||||
return { itemId: item.Id, storedName, serverRelativeUrl: added.ServerRelativeUrl };
|
||||
}
|
||||
|
||||
async function readFileContent(itemId: number): Promise<string> {
|
||||
const item = (await getSP()
|
||||
.web.lists.getByTitle(PAYMENT_INVOICES_LIBRARY_TITLE)
|
||||
.items.getById(itemId)
|
||||
.select(FILE_CONTENT_FIELD)()) as Record<string, unknown>;
|
||||
return typeof item[FILE_CONTENT_FIELD] === 'string' ? (item[FILE_CONTENT_FIELD] as string).trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить сохранённый счёт на OCR (POST /Document/extractPDFText) и вернуть
|
||||
* распознанный текст из колонки file_content. Сервис отвечает только после того,
|
||||
* как записал текст в колонку, поэтому читаем её сразу, без опроса.
|
||||
*/
|
||||
export async function recognizeInvoice(context: WebPartContext, itemId: number): Promise<string> {
|
||||
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||
const response: HttpClientResponse = await client.post(
|
||||
`${DICTIONARY_API_BASE_URL}/Document/extractPDFText`,
|
||||
AadHttpClient.configurations.v1,
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
siteUrl: DATA_SITE_URL,
|
||||
libraryName: PAYMENT_INVOICES_LIBRARY_TITLE,
|
||||
itemId: String(itemId)
|
||||
})
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Не удалось распознать счёт (Document/extractPDFText): ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const text = await readFileContent(itemId);
|
||||
if (!text) {
|
||||
throw new Error('Сервис распознавания не нашёл в счёте текста.');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { askGemini, IGeminiSchema } from '../../../shared/ai/gemini.service';
|
||||
import { IInvoiceItem, IPositionPair } from '../positions';
|
||||
import { IBasisPosition } from './basis.types';
|
||||
|
||||
/** Текст счёта режем: прокси ИИ ограничен по размеру запроса, а хвост счёта — реквизиты и подписи. */
|
||||
const MAX_INVOICE_TEXT_CHARS = 30000;
|
||||
|
||||
/** Что ИИ достаёт из текста счёта. */
|
||||
export interface IInvoiceData {
|
||||
invoiceNumber: string;
|
||||
supplierName: string;
|
||||
supplierBin: string;
|
||||
/** Счёт получателя (IBAN). */
|
||||
recipientAccount: string;
|
||||
currency: string;
|
||||
totalAmount: number;
|
||||
vatAmount: number;
|
||||
items: IInvoiceItem[];
|
||||
}
|
||||
|
||||
const STRING: IGeminiSchema = { type: 'string' };
|
||||
const NUMBER: IGeminiSchema = { type: 'number' };
|
||||
|
||||
const INVOICE_SCHEMA: IGeminiSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
invoiceNumber: STRING,
|
||||
supplierName: STRING,
|
||||
supplierBin: STRING,
|
||||
recipientAccount: STRING,
|
||||
currency: STRING,
|
||||
totalAmount: NUMBER,
|
||||
vatAmount: NUMBER,
|
||||
items: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { name: STRING, unit: STRING, quantity: NUMBER, price: NUMBER, sum: NUMBER },
|
||||
required: ['name', 'quantity']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['invoiceNumber', 'totalAmount', 'items']
|
||||
};
|
||||
|
||||
const PAIRS_SCHEMA: IGeminiSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pairs: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { baseIndex: { type: 'integer' }, invoiceIndex: { type: 'integer' }, note: STRING },
|
||||
required: ['baseIndex', 'invoiceIndex']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['pairs']
|
||||
};
|
||||
|
||||
/** Разобрать текст счёта: номер, поставщик, реквизиты, суммы, позиции. */
|
||||
export async function parseInvoiceText(context: WebPartContext, text: string): Promise<IInvoiceData> {
|
||||
const prompt =
|
||||
'Ниже распознанный текст счёта на оплату. Извлеки: номер счёта, наименование поставщика (продавца), ' +
|
||||
'его БИН/ИИН (12 цифр, пустая строка если нет), счёт получателя платежа (IBAN, например KZ…), ' +
|
||||
'валюту (код: KZT, USD, EUR, RUB, CNY), итоговую сумму к оплате, сумму НДС (0 если нет) и все позиции ' +
|
||||
'таблицы (наименование, единица измерения, количество, цена за единицу, сумма по строке). ' +
|
||||
'Числа — без пробелов и валютных знаков. Ничего не выдумывай: чего нет в тексте — пустая строка или 0.\n\n' +
|
||||
`Текст счёта:\n${text.slice(0, MAX_INVOICE_TEXT_CHARS)}`;
|
||||
const data = await askGemini<Partial<IInvoiceData>>(context, prompt, INVOICE_SCHEMA);
|
||||
return {
|
||||
invoiceNumber: data.invoiceNumber || '',
|
||||
supplierName: data.supplierName || '',
|
||||
supplierBin: (data.supplierBin || '').replace(/\D/g, ''),
|
||||
recipientAccount: (data.recipientAccount || '').replace(/\s/g, ''),
|
||||
currency: (data.currency || '').toUpperCase(),
|
||||
totalAmount: Number(data.totalAmount || 0),
|
||||
vatAmount: Number(data.vatAmount || 0),
|
||||
items: (data.items || []).map((item) => ({
|
||||
name: item.name || '',
|
||||
unit: item.unit || '',
|
||||
quantity: Number(item.quantity || 0),
|
||||
price: Number(item.price || 0),
|
||||
sum: Number(item.sum || 0)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Попросить ИИ сопоставить позиции основания (СТ/потребности) с позициями счёта.
|
||||
* Названия в счёте поставщик пишет по-своему, поэтому сопоставляем по смыслу;
|
||||
* количества и единицы потом сверяет код (см. buildRows), а не ИИ.
|
||||
*/
|
||||
export async function matchPositions(
|
||||
context: WebPartContext,
|
||||
base: IBasisPosition[],
|
||||
invoice: IInvoiceItem[]
|
||||
): Promise<IPositionPair[]> {
|
||||
if (base.length === 0 || invoice.length === 0) return [];
|
||||
const baseLines = base.map((p, i) => `${i}. ${p.name} (${p.unit}, ${p.qty})${p.supplierName ? ` — у поставщика: ${p.supplierName}` : ''}`);
|
||||
const invoiceLines = invoice.map((p, i) => `${i}. ${p.name} (${p.unit}, ${p.quantity})`);
|
||||
const prompt =
|
||||
'Сопоставь позиции заявки с позициями счёта поставщика. Одна позиция заявки соответствует не более чем ' +
|
||||
'одной позиции счёта и наоборот. Для каждой найденной пары верни baseIndex и invoiceIndex. ' +
|
||||
'Позиции без пары верни с индексом −1 с отсутствующей стороны. В note коротко (до 10 слов) опиши отличие ' +
|
||||
'в названии, если оно смысловое (другая марка, размер, материал); если названия по смыслу совпадают — пустая строка.\n\n' +
|
||||
`Позиции заявки:\n${baseLines.join('\n')}\n\nПозиции счёта:\n${invoiceLines.join('\n')}`;
|
||||
const result = await askGemini<{ pairs?: IPositionPair[] }>(context, prompt, PAIRS_SCHEMA);
|
||||
return (result.pairs || []).map((pair) => ({
|
||||
baseIndex: Number(pair.baseIndex),
|
||||
invoiceIndex: Number(pair.invoiceIndex),
|
||||
note: pair.note || ''
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../../../shared/config';
|
||||
import { IBasisInfo, IBasisOption, IBasisPosition } from './basis.types';
|
||||
|
||||
/** За сколько дней показываем потребности — как шаг выбора позиций в СТ. */
|
||||
const NEEDS_LOOKBACK_DAYS = 365;
|
||||
|
||||
type RawRecord = Record<string, unknown>;
|
||||
|
||||
interface INeed {
|
||||
id: string;
|
||||
requestNumber: string;
|
||||
project: string;
|
||||
section: string;
|
||||
workKind: string;
|
||||
organization: string;
|
||||
createdAt: string;
|
||||
positionsCount: number;
|
||||
}
|
||||
|
||||
async function apiGet(context: WebPartContext, path: string): Promise<unknown> {
|
||||
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||
const response: HttpClientResponse = await client.get(`${DICTIONARY_API_BASE_URL}${path}`, AadHttpClient.configurations.v1);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${path}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function mapNeed(item: RawRecord): INeed {
|
||||
return {
|
||||
id: String(item.id ?? ''),
|
||||
requestNumber: String(item.requestNumber ?? ''),
|
||||
// В API нет отдельной сущности «проект» — description и есть название проекта (как в шаге 1 СТ).
|
||||
project: String(item.description ?? ''),
|
||||
section: String(item.discipline ?? ''),
|
||||
workKind: String(item.workType ?? ''),
|
||||
organization: String(item.organization ?? ''),
|
||||
createdAt: String(item.requestDate ?? ''),
|
||||
positionsCount: Number(item.positionsCount ?? 0)
|
||||
};
|
||||
}
|
||||
|
||||
// Список потребностей большой и в рамках сессии не меняется — грузим один раз.
|
||||
let needsCache: Promise<INeed[]> | undefined;
|
||||
|
||||
function getNeeds(context: WebPartContext): Promise<INeed[]> {
|
||||
if (!needsCache) {
|
||||
needsCache = (apiGet(context, `/Requests/${NEEDS_LOOKBACK_DAYS}`) as Promise<RawRecord[]>)
|
||||
.then((rows) => rows.map(mapNeed))
|
||||
.catch((e) => {
|
||||
needsCache = undefined;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
return needsCache;
|
||||
}
|
||||
|
||||
/** Потребности для выбора основания. */
|
||||
export async function listNeeds(context: WebPartContext): Promise<IBasisOption[]> {
|
||||
const needs = await getNeeds(context);
|
||||
return needs.map((need) => ({
|
||||
id: need.id,
|
||||
title: `Потребность ${need.requestNumber}`,
|
||||
note: [need.project, need.organization].filter(Boolean).join(' · ')
|
||||
}));
|
||||
}
|
||||
|
||||
/** Основание «Потребность»: проект, раздел, вид работ и позиции без КП и победителя. */
|
||||
export async function loadNeedBasis(context: WebPartContext, needId: string): Promise<IBasisInfo> {
|
||||
const needs = await getNeeds(context);
|
||||
const need = needs.filter((n) => n.id === needId)[0];
|
||||
if (!need) {
|
||||
throw new Error('Потребность не найдена в списке — обновите страницу.');
|
||||
}
|
||||
const rows = (await apiGet(context, `/Requests/${encodeURIComponent(needId)}/positions`)) as RawRecord[];
|
||||
const positions: IBasisPosition[] = rows.map((row) => ({
|
||||
// Отдельного кода у позиции потребности нет; номер строки табличной части 1С
|
||||
// (lineNumber) уникален в пределах заявки — он и служит кодом позиции.
|
||||
code: row.lineNumber !== undefined && row.lineNumber !== null ? String(row.lineNumber) : '',
|
||||
name: String(row.materialName ?? ''),
|
||||
unit: String(row.unit ?? ''),
|
||||
qty: Number(row.quantity ?? 0),
|
||||
supplierName: '',
|
||||
supplierQty: 0,
|
||||
supplierPrice: 0
|
||||
}));
|
||||
return {
|
||||
kind: 'need',
|
||||
key: `need:${need.id}`,
|
||||
title: `Потребность ${need.requestNumber}`,
|
||||
projectName: need.project,
|
||||
section: need.section,
|
||||
workKind: need.workKind,
|
||||
companyHint: need.organization,
|
||||
recipientName: '',
|
||||
recipientBin: '',
|
||||
total: 0,
|
||||
currency: '',
|
||||
deliveryTerms: '',
|
||||
paymentTerms: '',
|
||||
deliveryTime: '',
|
||||
positions
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { REQUEST_STATUS, getBasisRequests, getRequestTypeIdByPageUrl } from '../../../shared/approval';
|
||||
import { FORM_ID, IPreviousPayment } from '../model';
|
||||
|
||||
/**
|
||||
* Статусы, которые в «уже оплачено/заявлено» не входят: черновик ещё не подан,
|
||||
* отклонённая и возвращённая заявки платёж не создают.
|
||||
*/
|
||||
const NOT_COUNTED_STATUSES: string[] = [REQUEST_STATUS.draft, REQUEST_STATUS.rejected, REQUEST_STATUS.returned];
|
||||
|
||||
/**
|
||||
* Прежние заявки на оплату по тому же основанию (СТ или потребности) — по всем
|
||||
* партициям списка.
|
||||
*
|
||||
* Читаем не список напрямую, а через approval-route (GET /api/basis-requests):
|
||||
* права на элементы заявок разорваны поштучно, и под своей учёткой заявитель
|
||||
* видел бы только свои оплаты — сумма по СТ занижалась бы и проверка превышения
|
||||
* пропускала переплату. Сервер отдаёт все заявки, а canOpen говорит, можно ли
|
||||
* текущему пользователю открыть каждую (без прав ссылки в таблице нет).
|
||||
*
|
||||
* excludeId — сама редактируемая заявка: себя в «ранее поданных» показывать нельзя.
|
||||
*/
|
||||
export async function loadPreviousPayments(
|
||||
context: WebPartContext,
|
||||
basisKey: string,
|
||||
excludeId?: number
|
||||
): Promise<IPreviousPayment[]> {
|
||||
if (!basisKey) return [];
|
||||
const requestTypeId = await getRequestTypeIdByPageUrl(context, FORM_ID);
|
||||
const rows = await getBasisRequests(context, requestTypeId, basisKey);
|
||||
|
||||
return rows
|
||||
.filter((row) => row.id !== excludeId && NOT_COUNTED_STATUSES.indexOf(row.status) === -1)
|
||||
.map((row) => ({
|
||||
code: row.uniqueCode || `№${row.id}`,
|
||||
amount: Number(row.amount || 0),
|
||||
id: row.canOpen ? row.id : undefined,
|
||||
listId: row.listId,
|
||||
status: row.status
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
||||
import { FORM_ID, IFormData } from './model';
|
||||
|
||||
/** Экземпляр сервиса списка согласования этой формы (см. approvalServiceFactory). */
|
||||
export function paymentRequestApprovalService(listId?: string): ApprovalListService {
|
||||
return approvalServiceFor(FORM_ID, listId);
|
||||
}
|
||||
|
||||
/** Осмысленный заголовок элемента (системная колонка Title). */
|
||||
function buildTitle(data: IFormData): string {
|
||||
const subject = data.RecipientName.trim() || data.BasisTitle;
|
||||
return ['Заявка на оплату', [subject, data.InvoiceNumber ? `счёт ${data.InvoiceNumber}` : ''].filter(Boolean).join(', ')]
|
||||
.filter(Boolean)
|
||||
.join(': ');
|
||||
}
|
||||
|
||||
/** Колонки, которые не копируются как есть: табличные части идут в JSON. */
|
||||
const JSON_KEYS: Array<keyof IFormData> = ['PreviousPayments', 'Positions'];
|
||||
|
||||
/** Поля элемента списка данных из модели формы. Пустая строка → null, чтобы не хранить пустые значения. */
|
||||
function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
||||
const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status };
|
||||
|
||||
(Object.keys(data) as Array<keyof IFormData>).forEach((key) => {
|
||||
if (JSON_KEYS.indexOf(key) !== -1) return;
|
||||
const value = data[key];
|
||||
item[key] = value === '' ? null : value;
|
||||
});
|
||||
|
||||
item.PreviousPaymentsJson = data.PreviousPayments.length > 0 ? JSON.stringify(data.PreviousPayments) : null;
|
||||
item.PositionsJson = data.Positions.length > 0 ? JSON.stringify(data.Positions) : null;
|
||||
return item;
|
||||
}
|
||||
|
||||
export type ISaveResult = ISaveItemResult;
|
||||
|
||||
/**
|
||||
* Сохранить «Заявку на оплату»: контент + маршрут согласования. existingId задан →
|
||||
* обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* listId — партиция, в которую пишем (см. resolveWriteListId).
|
||||
*/
|
||||
export async function savePaymentRequest(
|
||||
existingId: number | undefined,
|
||||
requestTypeId: number,
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
executors: IApproverToWrite[],
|
||||
status: string,
|
||||
listId?: string
|
||||
): Promise<ISaveResult> {
|
||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, buildItem(data, status));
|
||||
await writeRoute(paymentRequestApprovalService(listId), result.id, approvers, executors, status);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { getCompanies, getProjects } from '../../shared/dictionary';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import { basisPatch } from './basisPatch';
|
||||
import { BasisKind, IFormData } from './model';
|
||||
import { loadCtBasis } from './services/ctBasis.service';
|
||||
import { loadNeedBasis } from './services/needBasis.service';
|
||||
import { loadPreviousPayments } from './services/previousPayments.service';
|
||||
|
||||
export interface IBasisSelection {
|
||||
loading: boolean;
|
||||
error: string;
|
||||
/** Загрузить выбранную СТ/потребность и подставить её данные в форму. */
|
||||
select: (kind: BasisKind, id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбор основания оплаты. Справочники проектов и компаний нужны только для
|
||||
* подбора oid/Id по тексту (в СТ проект — строка), поэтому их сбой не мешает:
|
||||
* без них проект/компания просто выбираются руками.
|
||||
*/
|
||||
export function useBasisSelection(
|
||||
context: WebPartContext,
|
||||
patch: (changes: Partial<IFormData>) => void,
|
||||
editId: number | undefined
|
||||
): IBasisSelection {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const select = useCallback(
|
||||
async (kind: BasisKind, id: string): Promise<void> => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const info = kind === 'ct' ? await loadCtBasis(context, id) : await loadNeedBasis(context, id);
|
||||
const [previous, projects, companies] = await Promise.all([
|
||||
loadPreviousPayments(context, info.key, editId),
|
||||
getProjects(context).catch(() => []),
|
||||
getCompanies(context).catch(() => [])
|
||||
]);
|
||||
patch(basisPatch(info, previous, projects, companies));
|
||||
} catch (e) {
|
||||
setError(`Не удалось загрузить основание: ${friendlyErrorMessage(e)}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[context, patch, editId]
|
||||
);
|
||||
|
||||
return { loading, error, select };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import { IFormData } from './model';
|
||||
import { basisPositionsFromRows, invoicePatch } from './invoicePatch';
|
||||
import { recognizeInvoice, storeInvoice, validateInvoiceFile } from './services/invoice.service';
|
||||
import { matchPositions, parseInvoiceText } from './services/invoiceAi.service';
|
||||
|
||||
export type InvoiceStage = 'idle' | 'upload' | 'ocr' | 'analyze';
|
||||
|
||||
export const INVOICE_STAGE_TEXT: Record<InvoiceStage, string> = {
|
||||
idle: '',
|
||||
upload: 'Сохраняем счёт в библиотеку…',
|
||||
ocr: 'Распознаём текст счёта (OCR)…',
|
||||
analyze: 'ИИ разбирает счёт и сверяет позиции…'
|
||||
};
|
||||
|
||||
export interface IInvoiceFlow {
|
||||
stage: InvoiceStage;
|
||||
/** Ошибка последнего шага; файл при этом уже сохранён, форму можно заполнить руками. */
|
||||
error: string;
|
||||
attach: (file: File) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвейер счёта: библиотека → OCR → ИИ (реквизиты и позиции) → сверка с основанием.
|
||||
* Каждый шаг после сохранения файла необязателен: при сбое OCR или ИИ счёт остаётся
|
||||
* приложенным, а поля заполняются вручную — заявку из-за внешнего сервиса не блокируем.
|
||||
*/
|
||||
export function useInvoiceFlow(
|
||||
context: WebPartContext,
|
||||
data: IFormData,
|
||||
patch: (changes: Partial<IFormData>) => void
|
||||
): IInvoiceFlow {
|
||||
const [stage, setStage] = useState<InvoiceStage>('idle');
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const attach = useCallback(
|
||||
async (file: File): Promise<void> => {
|
||||
const invalid = validateInvoiceFile(file);
|
||||
if (invalid) {
|
||||
setError(invalid);
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
try {
|
||||
setStage('upload');
|
||||
const stored = await storeInvoice(file);
|
||||
patch({ InvoiceFileName: file.name, InvoiceUrl: stored.serverRelativeUrl });
|
||||
|
||||
setStage('ocr');
|
||||
const text = await recognizeInvoice(context, stored.itemId);
|
||||
|
||||
setStage('analyze');
|
||||
const invoice = await parseInvoiceText(context, text);
|
||||
const pairs = await matchPositions(context, basisPositionsFromRows(data.Positions), invoice.items);
|
||||
patch(invoicePatch(data, invoice, invoice.items, pairs));
|
||||
} catch (e) {
|
||||
setError(`${friendlyErrorMessage(e)} Заполните поля по счёту вручную.`);
|
||||
} finally {
|
||||
setStage('idle');
|
||||
}
|
||||
},
|
||||
[context, data, patch]
|
||||
);
|
||||
|
||||
return { stage, error, attach };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IFormData } from './model';
|
||||
|
||||
/**
|
||||
* Обязательные поля заявки на оплату. Возвращает текст ошибки или undefined.
|
||||
* Превышение общей суммы и расхождения со счётом сюда НЕ входят: они только
|
||||
* подсвечиваются, решение — за согласующими.
|
||||
*/
|
||||
export function validatePayment(data: IFormData): string | undefined {
|
||||
const missing: string[] = [];
|
||||
if (!data.RequesterEmail) missing.push('заявитель');
|
||||
if (!data.BasisKey) missing.push('основание (СТ или потребность)');
|
||||
if (!data.ProjectName.trim()) missing.push('проект');
|
||||
if (!data.CompanyName.trim()) missing.push('компания');
|
||||
if (!data.BankAccountName.trim()) missing.push('банковский счёт');
|
||||
if (!data.RecipientName.trim()) missing.push('получатель');
|
||||
if (!data.RecipientAccount.trim()) missing.push('счёт получателя');
|
||||
if (!data.CashFlowGroupName.trim()) missing.push('группа статьи ДДС');
|
||||
if (!data.CashFlowItemName.trim()) missing.push('статья ДДС');
|
||||
if (!data.PaymentPurpose.trim()) missing.push('назначение платежа');
|
||||
if (data.PayAmount <= 0) missing.push('сумма оплаты');
|
||||
if (!data.InvoiceFileName) missing.push('счёт на оплату');
|
||||
return missing.length > 0 ? `Заполните обязательные поля: ${missing.join(', ')}.` : undefined;
|
||||
}
|
||||
@@ -17,6 +17,8 @@
|
||||
(`CompanyPickerMulti`), хранится JSON-массивом `{Id,Name}` в Note-колонке `CompaniesJson`.
|
||||
- **График работы (WorkSchedule)** — выпадающий список (набор совпадает с «Заявкой
|
||||
на подбор»); при выборе «Другое» появляется ручной ввод `WorkScheduleOther`.
|
||||
- **Время работы (WorkHours)** — так же, как график: набор и колонка сайта общие
|
||||
с «Заявкой на подбор», оттуда подставляется; «Другое» → `WorkHoursOther`.
|
||||
- **Испытательный срок (ProbationPeriod)** — выпадающий список частых сроков.
|
||||
- **Рекрутинговое агентство (условный блок, последняя секция)** — `Toggle`
|
||||
`RecruitingAgency`; при включении показываются «Название компании» (`AgencyName`)
|
||||
|
||||
@@ -59,6 +59,9 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { draftIssues, submitIssues } from './validation';
|
||||
import { saveHireRequest, hireApprovalService } from './submit';
|
||||
import { loadHireRequest } from './load';
|
||||
import { isApprovedStatus } from '../../shared/approval/models/statuses';
|
||||
import { loadSelectionRequest } from '../RecruitingSelection/load';
|
||||
import { selectionPrefill } from './selectionPrefill';
|
||||
import {
|
||||
ATT_CATEGORY_ID,
|
||||
ATT_CATEGORY_RESUME,
|
||||
@@ -73,6 +76,7 @@ import { PpeSection } from './sections/PpeSection';
|
||||
import { ConditionsSection } from './sections/ConditionsSection';
|
||||
import { AgencySection } from './sections/AgencySection';
|
||||
import { NoteSection } from './sections/NoteSection';
|
||||
import { SecuritySection } from './sections/SecuritySection';
|
||||
import { useStableCallback } from '../../shared/useStableCallback';
|
||||
|
||||
const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||
@@ -82,6 +86,8 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||
onSaved,
|
||||
editId,
|
||||
editListId,
|
||||
sourceId,
|
||||
sourceListId,
|
||||
onCodeLoaded
|
||||
}) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
@@ -140,6 +146,31 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||
};
|
||||
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||
|
||||
// Новая заявка «на основе» заявки на подбор (кнопка «Оформить приём по этой
|
||||
// заявке» в её просмотре): связь 1.1 + те же поля, что при выборе подбора
|
||||
// вручную (selectionPrefill). Приём оформляют только по утверждённому подбору —
|
||||
// как и в списке выбора поля 1.1.
|
||||
useEffect(() => {
|
||||
if (editId || !sourceId) return;
|
||||
let alive = true;
|
||||
loadSelectionRequest(sourceId, sourceListId)
|
||||
.then((loaded) => {
|
||||
if (!alive) return;
|
||||
if (!isApprovedStatus(loaded.status)) {
|
||||
setSubmitError(`Заявка на подбор №${loaded.code || sourceId} ещё не согласована — приём по ней оформить нельзя.`);
|
||||
return;
|
||||
}
|
||||
const link = { id: sourceId, listId: sourceListId || '', code: loaded.code };
|
||||
setData((prev) => ({ ...prev, ...selectionPrefill(link, loaded.data) }));
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setSubmitError(`Не удалось предзаполнить из заявки на подбор №${sourceId}: ${friendlyErrorMessage(e)}`);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [editId, sourceId, sourceListId, setSubmitError]);
|
||||
|
||||
// Маршрут согласования (планируемый) резолвится по email инициатора — текущего
|
||||
// пользователя (кандидата ещё нет в системе). У заявки, открытой на
|
||||
// редактирование, маршрут читается из уже записанных строк — планирование
|
||||
@@ -409,6 +440,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||
<PpeSection {...sectionProps} />
|
||||
<ConditionsSection {...sectionProps} />
|
||||
<AgencySection {...sectionProps} />
|
||||
<SecuritySection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
</FieldChangesProvider>
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ import { PpeSection } from './sections/PpeSection';
|
||||
import { ConditionsSection } from './sections/ConditionsSection';
|
||||
import { AgencySection } from './sections/AgencySection';
|
||||
import { NoteSection } from './sections/NoteSection';
|
||||
import { SecuritySection } from './sections/SecuritySection';
|
||||
import { ApproverNoteBanner } from '../../shared/approverNote';
|
||||
import { useStableCallback } from '../../shared/useStableCallback';
|
||||
|
||||
@@ -416,6 +417,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
<PpeSection {...sectionProps} />
|
||||
<ConditionsSection {...sectionProps} />
|
||||
<AgencySection {...sectionProps} />
|
||||
<SecuritySection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
</FieldChangesProvider>
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [
|
||||
'HireCandidateName', 'HireHireDate', 'Position',
|
||||
'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'ProbationPeriodOther',
|
||||
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
|
||||
'AgencyName', 'AgencyTerms', 'HireApproverNote',
|
||||
'WorkHours', 'WorkHoursOther',
|
||||
'AgencyName', 'AgencyTerms', 'HireSecurityNote', 'HireApproverNote',
|
||||
'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode'
|
||||
];
|
||||
|
||||
@@ -62,6 +63,8 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
|
||||
'ProjectCode',
|
||||
'RecruitingAgency',
|
||||
'HireOnArrival',
|
||||
'HireSecurityApproved',
|
||||
'PieceworkPay',
|
||||
'HireApproverNoteImportant',
|
||||
APPROVER_CHANGES_FIELD,
|
||||
'RequestStatus',
|
||||
@@ -79,6 +82,8 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
|
||||
// Флаг агентства — булево поле (Yes/No), не строка.
|
||||
(data as unknown as Record<string, unknown>).RecruitingAgency = !!item.RecruitingAgency;
|
||||
(data as unknown as Record<string, unknown>).HireOnArrival = !!item.HireOnArrival;
|
||||
data.HireSecurityApproved = !!item.HireSecurityApproved;
|
||||
data.PieceworkPay = !!item.PieceworkPay;
|
||||
data.HireApproverNoteImportant = !!item.HireApproverNoteImportant;
|
||||
|
||||
SCALAR_FIELDS.forEach((key) => {
|
||||
|
||||
@@ -75,10 +75,27 @@ export interface IFormData {
|
||||
SalaryAfterProbation: string;
|
||||
WorkSchedule: string;
|
||||
WorkScheduleOther: string;
|
||||
/**
|
||||
* Время работы — та же колонка сайта WorkHours (Choice), что в заявке на подбор:
|
||||
* оттуда и подставляется (см. selectionPrefill).
|
||||
*/
|
||||
WorkHours: string;
|
||||
WorkHoursOther: string;
|
||||
/**
|
||||
* Сдельная оплата труда — информационная отметка, на обязательность окладов не
|
||||
* влияет. Та же колонка сайта PieceworkPay (Boolean), что в заявке на подбор:
|
||||
* оттуда и подставляется (см. selectionPrefill).
|
||||
*/
|
||||
PieceworkPay: boolean;
|
||||
// Рекрутинговое агентство (условный блок): при включённом флаге — название + условия.
|
||||
RecruitingAgency: boolean;
|
||||
AgencyName: string;
|
||||
AgencyTerms: string;
|
||||
// Служба безопасности: инициатор при подаче отмечает, что кандидат согласован
|
||||
// с СБ; примечание при отметке — по желанию. Информационное —
|
||||
// на маршрут и решения не влияет.
|
||||
HireSecurityApproved: boolean;
|
||||
HireSecurityNote: string;
|
||||
// Примечание для согласующих (shared/approverNote): текст + «Важное».
|
||||
// Префикс Hire — чтобы internalName не совпал с одноимёнными колонками других форм.
|
||||
HireApproverNote: string;
|
||||
@@ -121,9 +138,14 @@ export const EMPTY_DATA: IFormData = {
|
||||
SalaryAfterProbation: '',
|
||||
WorkSchedule: '',
|
||||
WorkScheduleOther: '',
|
||||
WorkHours: '',
|
||||
WorkHoursOther: '',
|
||||
PieceworkPay: false,
|
||||
RecruitingAgency: false,
|
||||
AgencyName: '',
|
||||
AgencyTerms: '',
|
||||
HireSecurityApproved: false,
|
||||
HireSecurityNote: '',
|
||||
HireApproverNote: '',
|
||||
HireApproverNoteImportant: false
|
||||
};
|
||||
@@ -187,5 +209,19 @@ export const WORK_SCHEDULE_OPTIONS: ISelectOption[] = [
|
||||
{ value: OTHER_VALUE, label: OTHER_VALUE }
|
||||
];
|
||||
|
||||
/**
|
||||
* Варианты «Время работы» (24-часовой формат) — совпадают с набором в форме
|
||||
* «Заявка на подбор» (WORK_HOURS_OPTIONS) и с choices колонки WorkHours:
|
||||
* формы независимы, значения продублированы намеренно.
|
||||
*/
|
||||
export const WORK_HOURS_OPTIONS: ISelectOption[] = [
|
||||
{ value: '08:00-17:00', label: '08:00-17:00' },
|
||||
{ value: '09:00-18:00', label: '09:00-18:00' },
|
||||
{ value: '07:00-19:00', label: '07:00-19:00' },
|
||||
{ value: '08:00-20:00', label: '08:00-20:00' },
|
||||
{ value: '09:00-21:00', label: '09:00-21:00' },
|
||||
{ value: OTHER_VALUE, label: OTHER_VALUE }
|
||||
];
|
||||
|
||||
/** Плейсхолдер для полей-людей. */
|
||||
export const PERSON_PLACEHOLDER = 'Начните вводить имя сотрудника…';
|
||||
|
||||
@@ -41,11 +41,17 @@
|
||||
{ "internalName": "SalaryAfterProbation", "title": "Оклад по завершении ИС", "type": "Number", "decimals": 2 },
|
||||
{ "internalName": "WorkSchedule", "title": "График работы", "type": "Text" },
|
||||
{ "internalName": "WorkScheduleOther", "title": "График работы (другое)", "type": "Text" },
|
||||
{ "internalName": "WorkHours", "title": "Время работы", "type": "Choice", "choices": ["08:00-17:00", "09:00-18:00", "07:00-19:00", "08:00-20:00", "09:00-21:00", "Другое"] },
|
||||
{ "internalName": "WorkHoursOther", "title": "Время работы (другое)", "type": "Text" },
|
||||
{ "internalName": "PieceworkPay", "title": "Сдельная оплата труда", "type": "Boolean" },
|
||||
|
||||
{ "internalName": "RecruitingAgency", "title": "Рекрутинговое агентство", "type": "Boolean" },
|
||||
{ "internalName": "AgencyName", "title": "Название компании (агентство)", "type": "Text" },
|
||||
{ "internalName": "AgencyTerms", "title": "Условия (агентство)", "type": "Note" },
|
||||
|
||||
{ "internalName": "HireSecurityApproved", "title": "Согласован СБ", "type": "Boolean" },
|
||||
{ "internalName": "HireSecurityNote", "title": "Примечание СБ", "type": "Note" },
|
||||
|
||||
{ "internalName": "HireApproverNote", "title": "Примечание для согласующих", "type": "Note" },
|
||||
{ "internalName": "HireApproverNoteImportant", "title": "Примечание — важное", "type": "Boolean" }
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, TextInput, MoneyInput, Select } from '../../../shared/ui';
|
||||
import { ISectionProps, OTHER_VALUE, WORK_SCHEDULE_OPTIONS, PROBATION_PERIOD_OPTIONS } from '../model';
|
||||
import { CollapseSection, FormField, TextInput, MoneyInput, Select, Toggle } from '../../../shared/ui';
|
||||
import { ISectionProps, OTHER_VALUE, WORK_SCHEDULE_OPTIONS, WORK_HOURS_OPTIONS, PROBATION_PERIOD_OPTIONS } from '../model';
|
||||
|
||||
interface IConditionsSectionProps extends ISectionProps {
|
||||
/** Начальник расчётной группы правит оклады даже в режиме просмотра (см. approverRights.ts). */
|
||||
@@ -67,5 +67,30 @@ export const ConditionsSection: React.FC<IConditionsSectionProps> = ({ data, set
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="4.5 Время работы" htmlFor="WorkHours" required>
|
||||
<Select
|
||||
id="WorkHours"
|
||||
value={data.WorkHours}
|
||||
onChange={(v) => {
|
||||
set('WorkHours', v);
|
||||
if (v !== OTHER_VALUE) set('WorkHoursOther', '');
|
||||
}}
|
||||
options={WORK_HOURS_OPTIONS}
|
||||
placeholder="— выберите —"
|
||||
/>
|
||||
</FormField>
|
||||
{data.WorkHours === OTHER_VALUE && (
|
||||
<FormField label="Время работы (другое)" htmlFor="WorkHoursOther">
|
||||
<TextInput
|
||||
id="WorkHoursOther"
|
||||
value={data.WorkHoursOther}
|
||||
onChange={(v) => set('WorkHoursOther', v)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
{/* Просто отметка: обязательность окладов (4.2, 4.3) от неё не зависит. */}
|
||||
<FormField label="4.6 Сдельная оплата труда" fieldKey="PieceworkPay">
|
||||
<Toggle checked={data.PieceworkPay} onChange={(v) => set('PieceworkPay', v)} onLabel="Да" />
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
);
|
||||
|
||||
@@ -8,10 +8,10 @@ const NOTE_FIELD_KEYS: IApproverNoteFieldKeys = {
|
||||
important: 'HireApproverNoteImportant'
|
||||
};
|
||||
|
||||
/** 6. Примечание для согласующих — общий раздел shared/approverNote на полях приёма. */
|
||||
/** 7. Примечание для согласующих — общий раздел shared/approverNote на полях приёма. */
|
||||
export const NoteSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||
<ApproverNoteSection
|
||||
title="6. Примечание для согласующих"
|
||||
title="7. Примечание для согласующих"
|
||||
value={{ text: data.HireApproverNote, important: data.HireApproverNoteImportant }}
|
||||
onChange={(note) => {
|
||||
set('HireApproverNote', note.text);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, TextArea, Toggle } from '../../../shared/ui';
|
||||
import { ISectionProps } from '../model';
|
||||
|
||||
/**
|
||||
* 6. Служба безопасности: инициатор отмечает, что кандидат согласован с СБ, и
|
||||
* при желании пишет примечание (необязательно). Поле информационное —
|
||||
* на маршрут и решения согласующих не влияет. При снятии отметки примечание
|
||||
* очищается, чтобы в заявку не ушёл текст скрытого поля (как в AgencySection).
|
||||
*/
|
||||
export const SecuritySection: React.FC<ISectionProps> = ({ data, set }) => {
|
||||
const handleToggle = (value: boolean): void => {
|
||||
set('HireSecurityApproved', value);
|
||||
if (!value) set('HireSecurityNote', '');
|
||||
};
|
||||
|
||||
return (
|
||||
<CollapseSection title="6. Служба безопасности" icon="user-check">
|
||||
<FormField label="6.1 Согласован СБ" fieldKey="HireSecurityApproved">
|
||||
<Toggle checked={data.HireSecurityApproved} onChange={handleToggle} onLabel="Да" />
|
||||
</FormField>
|
||||
{data.HireSecurityApproved && (
|
||||
<FormField label="6.2 Примечание СБ" htmlFor="HireSecurityNote" full>
|
||||
<TextArea
|
||||
id="HireSecurityNote"
|
||||
value={data.HireSecurityNote}
|
||||
onChange={(v) => set('HireSecurityNote', v)}
|
||||
placeholder="Кто и когда согласовал, замечания СБ"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</CollapseSection>
|
||||
);
|
||||
};
|
||||
+11
-33
@@ -5,7 +5,14 @@ import { routeUrl } from '../../../shared/routing';
|
||||
import { getApprovedSelectionRequests, ISelectionRequestOption } from '../selectionLookup';
|
||||
import { loadSelectionRequest } from '../../RecruitingSelection/load';
|
||||
import { IFormData, SetField } from '../model';
|
||||
import { hireTypeFromSelection } from '../hireTypeFromSelection';
|
||||
import { selectionPrefill } from '../selectionPrefill';
|
||||
|
||||
/** Записать набор полей через SetField формы — по одному ключу. */
|
||||
function applyPatch(set: SetField, patch: Partial<IFormData>): void {
|
||||
(Object.keys(patch) as (keyof IFormData)[]).forEach((key) => {
|
||||
set(key, patch[key] as IFormData[typeof key]);
|
||||
});
|
||||
}
|
||||
|
||||
/** Слаг формы подбора в реестре форм — цель ссылки (rptype). */
|
||||
const SELECTION_FORM_KEY = 'recruiting-selection';
|
||||
@@ -91,40 +98,11 @@ export const SelectionRequestField: React.FC<ISelectionRequestFieldProps> = ({ d
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
|
||||
// Максимум полей, общих с заявкой на подбор, подтягиваем из неё — перезаписываем
|
||||
// даже уже заполненные вручную (пользователь осознанно выбрал/сменил связь, см.
|
||||
// обсуждение с Тимуром: перезапись — ожидаемое поведение при смене связанной заявки).
|
||||
// Максимум полей, общих с заявкой на подбор, подтягиваем из неё (см.
|
||||
// selectionPrefill — та же логика у «Оформить приём» из просмотра подбора).
|
||||
loadSelectionRequest(o.id, o.listId || undefined)
|
||||
.then(({ data: selection }) => {
|
||||
set('Companies', selection.Companies);
|
||||
set('MainCompanyId', ''); // компании заменены целиком — зависимые поля пересчитывать не от чего
|
||||
set('CompanyShares', []);
|
||||
// В подборе проект один — он становится единственным выбранным проектом приёма.
|
||||
set(
|
||||
'Projects',
|
||||
selection.ObjectProjectId
|
||||
? [{ Id: selection.ObjectProjectId, Name: selection.ObjectProject, Code: selection.ObjectProjectCode }]
|
||||
: []
|
||||
);
|
||||
set('Position', selection.Position);
|
||||
set('Department', selection.Department);
|
||||
set('WorkSchedule', selection.WorkSchedule);
|
||||
set('WorkScheduleOther', selection.WorkScheduleOther);
|
||||
set('ProbationPeriod', selection.SelectionProbationPeriod);
|
||||
set('ProbationPeriodOther', selection.SelectionProbationPeriodOther);
|
||||
set('SalaryProbation', selection.RateProbation);
|
||||
set('SalaryAfterProbation', selection.RateAfterProbation);
|
||||
// Тип приёма однозначен, только если в подборе ОДНА причина; ФИО «взамен
|
||||
// кого» — только если под ней ОДИН сотрудник. Иначе инициатор выбирает сам.
|
||||
const reasons = selection.VacancyReasons;
|
||||
const single = reasons.length === 1 ? reasons[0] : undefined;
|
||||
const hireTypePrefill = single
|
||||
? hireTypeFromSelection(single.reason, single.employees.length === 1 ? single.employees[0] : '')
|
||||
: undefined;
|
||||
if (hireTypePrefill) {
|
||||
set('HireType', hireTypePrefill.hireType);
|
||||
set('HireReplacedEmployee', hireTypePrefill.replacedEmployee);
|
||||
}
|
||||
applyPatch(set, selectionPrefill({ id: o.id, listId: o.listId, code: o.code }, selection));
|
||||
})
|
||||
.catch(() => undefined); // связь уже сохранена по id/коду — сбой обогащения не критичен
|
||||
};
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { IFormData as ISelectionData } from '../RecruitingSelection/model';
|
||||
import { hireTypeFromSelection } from './hireTypeFromSelection';
|
||||
import { IFormData } from './model';
|
||||
|
||||
/** Какая заявка на подбор связывается с приёмом (см. SelectionRequestField). */
|
||||
export interface ISelectionLink {
|
||||
id: number;
|
||||
/** Url партиции подбора; '' — неизвестна. */
|
||||
listId: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Поля приёма, которые берутся из связанной заявки на подбор, + сама связь.
|
||||
* Одна функция на оба пути: выбор подбора в поле 1.1 (SelectionRequestField) и
|
||||
* «+ Оформить приём по этой заявке» из просмотра подбора — чтобы они не
|
||||
* разошлись. Уже заполненное вручную перезаписывается: пользователь осознанно
|
||||
* выбрал/сменил связь.
|
||||
*/
|
||||
export function selectionPrefill(link: ISelectionLink, selection: ISelectionData): Partial<IFormData> {
|
||||
const patch: Partial<IFormData> = {
|
||||
SelectionRequestId: String(link.id),
|
||||
SelectionRequestListId: link.listId,
|
||||
SelectionRequestCode: link.code,
|
||||
Companies: selection.Companies,
|
||||
// Компании заменены целиком — зависимые поля пересчитывать не от чего.
|
||||
MainCompanyId: '',
|
||||
CompanyShares: [],
|
||||
// В подборе проект один — он становится единственным выбранным проектом приёма.
|
||||
Projects: selection.ObjectProjectId
|
||||
? [{ Id: selection.ObjectProjectId, Name: selection.ObjectProject, Code: selection.ObjectProjectCode }]
|
||||
: [],
|
||||
Position: selection.Position,
|
||||
Department: selection.Department,
|
||||
WorkSchedule: selection.WorkSchedule,
|
||||
WorkScheduleOther: selection.WorkScheduleOther,
|
||||
WorkHours: selection.WorkHours,
|
||||
WorkHoursOther: selection.WorkHoursOther,
|
||||
ProbationPeriod: selection.SelectionProbationPeriod,
|
||||
ProbationPeriodOther: selection.SelectionProbationPeriodOther,
|
||||
SalaryProbation: selection.RateProbation,
|
||||
SalaryAfterProbation: selection.RateAfterProbation,
|
||||
PieceworkPay: selection.PieceworkPay,
|
||||
};
|
||||
|
||||
// Тип приёма однозначен, только если в подборе ОДНА причина; ФИО «взамен
|
||||
// кого» — только если под ней ОДИН сотрудник. Иначе инициатор выбирает сам.
|
||||
const reasons = selection.VacancyReasons;
|
||||
const single = reasons.length === 1 ? reasons[0] : undefined;
|
||||
const hireType = single
|
||||
? hireTypeFromSelection(single.reason, single.employees.length === 1 ? single.employees[0] : '')
|
||||
: undefined;
|
||||
if (hireType) {
|
||||
patch.HireType = hireType.hireType;
|
||||
patch.HireReplacedEmployee = hireType.replacedEmployee;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
@@ -25,7 +25,8 @@ describe('RecruitingHire validation', () => {
|
||||
'4.1 Испытательный срок',
|
||||
'4.2 Оклад при испытательном сроке',
|
||||
'4.3 Оклад по завершении ИС',
|
||||
'4.4 График работы'
|
||||
'4.4 График работы',
|
||||
'4.5 Время работы'
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -38,6 +39,11 @@ describe('RecruitingHire validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('примечание СБ необязательно, даже при отметке «Согласован СБ»', () => {
|
||||
expect(labels(submitIssues({ ...DRAFT_READY, HireSecurityApproved: true }))).not.toContain('6.2 Примечание СБ');
|
||||
expect(draftIssues({ ...DRAFT_READY, HireSecurityApproved: true })).toEqual([]);
|
||||
});
|
||||
|
||||
it('«По приезду на объект» снимает обязательность даты приёма', () => {
|
||||
expect(labels(submitIssues({ ...DRAFT_READY, HireOnArrival: true }))).not.toContain('2.2 Дата приёма');
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ interface IRequiredRule extends IFieldIssue {
|
||||
* агентство» не проверяется — у булевых нет пустого состояния.
|
||||
* 2.2 «Дата приёма» не нужна, если отмечено «По приезду на объект»
|
||||
* (HireOnArrival) — тогда дату проставляет исполнитель на своём шаге.
|
||||
* 6.2 «Примечание СБ» необязательно (по просьбе заказчика) — даже при отметке 6.1.
|
||||
*
|
||||
* 1.3 «Проект» — хотя бы один проект из справочника: первый уходит в resolve
|
||||
* маршрута (projectId), а без него approval-route отвечал 404 и заявка ушла бы
|
||||
@@ -48,7 +49,8 @@ const REQUIRED_RULES: ReadonlyArray<IRequiredRule> = [
|
||||
{ field: 'ProbationPeriod', label: '4.1 Испытательный срок', ok: (d) => !!d.ProbationPeriod },
|
||||
{ field: 'SalaryProbation', label: '4.2 Оклад при испытательном сроке', ok: (d) => !!d.SalaryProbation.trim() },
|
||||
{ field: 'SalaryAfterProbation', label: '4.3 Оклад по завершении ИС', ok: (d) => !!d.SalaryAfterProbation.trim() },
|
||||
{ field: 'WorkSchedule', label: '4.4 График работы', ok: (d) => !!d.WorkSchedule }
|
||||
{ field: 'WorkSchedule', label: '4.4 График работы', ok: (d) => !!d.WorkSchedule },
|
||||
{ field: 'WorkHours', label: '4.5 Время работы', ok: (d) => !!d.WorkHours }
|
||||
];
|
||||
|
||||
function toIssue({ field, label, message }: IRequiredRule): IFieldIssue {
|
||||
|
||||
@@ -27,4 +27,7 @@ ApplicationSection · DepartmentSection · VacancySection · HealthTripsSection
|
||||
## Точки входа
|
||||
- rptype: `recruiting-selection`
|
||||
- Режим согласующего (просмотр): есть (`RecruitingSelectionView`)
|
||||
- «Создать на основе»: —
|
||||
- «Создать на основе»: из согласованного подбора — «+ Оформить приём по этой заявке»
|
||||
(`sections/StartHireBar`, под вложениями) → `recruiting-hire` с `rpsource`=Id подбора и
|
||||
`rplist`=его партиция; форма приёма заполняет поля через `RecruitingHire/selectionPrefill.ts`
|
||||
(та же логика, что при выборе подбора в поле 1.1)
|
||||
|
||||
@@ -57,6 +57,11 @@ import { VacancySection } from './sections/VacancySection';
|
||||
import { HealthTripsSection } from './sections/HealthTripsSection';
|
||||
import { ConditionsSection } from './sections/ConditionsSection';
|
||||
import { HiresProgressSection } from './sections/HiresProgressSection';
|
||||
import { StartHireBar } from './sections/StartHireBar';
|
||||
import { useLinkedHires } from './useLinkedHires';
|
||||
import { FORM_ID as HIRE_FORM_ID } from '../RecruitingHire/model';
|
||||
import { navigate } from '../../shared/routing';
|
||||
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
|
||||
import { parseVacanciesCount } from './vacancyReasons';
|
||||
import { useStableCallback } from '../../shared/useStableCallback';
|
||||
|
||||
@@ -289,6 +294,22 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
handleStepAdded,
|
||||
setRouteError
|
||||
);
|
||||
// Приёмы по этому подбору — один запрос на оба блока: «Приём по заявке» и
|
||||
// «Оформить приём» под вложениями. Только для согласованного подбора.
|
||||
const linkedHires = useLinkedHires(itemId, listId || '', isApprovedStatus(status));
|
||||
|
||||
// Новая заявка на приём, связанная с этим подбором и заполненная из него (см.
|
||||
// RecruitingHireForm/selectionPrefill). Несохранённые правки в другой новой
|
||||
// заявке потерялись бы — спрашиваем, как при «рабочем месте на основе приёма».
|
||||
const handleStartHire = (): void => {
|
||||
confirmDiscardFormChanges()
|
||||
.then((ok) => {
|
||||
if (!ok) return;
|
||||
navigate({ mode: 'create', nav: 'new', formKey: HIRE_FORM_ID, sourceId: itemId, listId });
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
// По статусу заявки: согласующих — пока идёт согласование, исполнителей —
|
||||
// пока не завершено исполнение. Сервер перепроверит по строкам (routeStepAdd.ts).
|
||||
const canAddApprover = canRemoveApprover && status === REQUEST_STATUS.onApproval;
|
||||
@@ -374,11 +395,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
<DepartmentSection {...sectionProps} />
|
||||
{/* Приём оформляют только по утверждённому подбору — до этого блоку показывать нечего. */}
|
||||
{isApprovedStatus(status) && (
|
||||
<HiresProgressSection
|
||||
selectionId={itemId}
|
||||
selectionListId={listId || ''}
|
||||
vacancies={parseVacanciesCount(data.OpenVacanciesCount)}
|
||||
/>
|
||||
<HiresProgressSection linked={linkedHires} vacancies={parseVacanciesCount(data.OpenVacanciesCount)} />
|
||||
)}
|
||||
<VacancySection {...sectionProps} />
|
||||
<HealthTripsSection {...sectionProps} />
|
||||
@@ -389,6 +406,15 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
<div className="rp-no-print">
|
||||
<EditableAttachments editor={attachments} editable={fullEditable} />
|
||||
</div>
|
||||
|
||||
{/* После вложений, перед маршрутом: приём оформляют, уже просмотрев подбор целиком. */}
|
||||
{isApprovedStatus(status) && (
|
||||
<StartHireBar
|
||||
linked={linkedHires}
|
||||
vacancies={parseVacanciesCount(data.OpenVacanciesCount)}
|
||||
onStart={handleStartHire}
|
||||
/>
|
||||
)}
|
||||
</FormCard>
|
||||
|
||||
{routeError && <Alert variant="err">{routeError}</Alert>}
|
||||
|
||||
+7
-41
@@ -3,14 +3,13 @@ import styles from './HiresProgressSection.module.scss';
|
||||
import { Alert, CollapseSection, FormField } from '../../../shared/ui';
|
||||
import { StatusBadge } from '../../../shared/approval';
|
||||
import { routeUrl } from '../../../shared/routing';
|
||||
import { friendlyErrorMessage } from '../../../shared/diagnostics';
|
||||
import { FORM_ID as HIRE_FORM_ID } from '../../RecruitingHire/model';
|
||||
import { getLinkedHires, ILinkedHire, isOccupyingHireStatus } from '../../RecruitingHire/linkedHires';
|
||||
import { ILinkedHire } from '../../RecruitingHire/linkedHires';
|
||||
import { ILinkedHiresState, occupiedLabel } from '../useLinkedHires';
|
||||
|
||||
interface IHiresProgressSectionProps {
|
||||
selectionId: number;
|
||||
/** Партиция, где лежит заявка на подбор ('' — неизвестна). */
|
||||
selectionListId: string;
|
||||
/** Заявки на приём по этому подбору (см. useLinkedHires — грузятся один раз на просмотр). */
|
||||
linked: ILinkedHiresState;
|
||||
/** Количество открытых вакансий из 2.2. 0 — не указано. */
|
||||
vacancies: number;
|
||||
}
|
||||
@@ -20,14 +19,9 @@ const TEXT = {
|
||||
occupied: 'Занято вакансий',
|
||||
hires: 'Заявки на приём',
|
||||
loading: 'Загрузка…',
|
||||
empty: 'Заявок на приём по этому подбору пока нет.',
|
||||
loadError: 'Не удалось загрузить заявки на приём'
|
||||
empty: 'Заявок на приём по этому подбору пока нет.'
|
||||
};
|
||||
|
||||
function occupiedLabel(occupied: number, vacancies: number): string {
|
||||
return vacancies > 0 ? `${occupied} из ${vacancies}` : String(occupied);
|
||||
}
|
||||
|
||||
function hireHref(hire: ILinkedHire): string {
|
||||
return routeUrl({
|
||||
mode: 'approve',
|
||||
@@ -44,36 +38,8 @@ function hireHref(hire: ILinkedHire): string {
|
||||
* Правило «приём занимает вакансию» — то же, что и в выборе подбора в форме
|
||||
* приёма (см. RecruitingHire/linkedHires.ts).
|
||||
*/
|
||||
export const HiresProgressSection: React.FC<IHiresProgressSectionProps> = ({
|
||||
selectionId,
|
||||
selectionListId,
|
||||
vacancies
|
||||
}) => {
|
||||
const [hires, setHires] = React.useState<ILinkedHire[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||
const [error, setError] = React.useState<string>('');
|
||||
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
setIsLoading(true);
|
||||
getLinkedHires(selectionId, selectionListId)
|
||||
.then((rows) => {
|
||||
if (!alive) return;
|
||||
setHires(rows);
|
||||
setError('');
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setError(`${TEXT.loadError}: ${friendlyErrorMessage(e)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [selectionId, selectionListId]);
|
||||
|
||||
const occupied = hires.filter((h) => isOccupyingHireStatus(h.status)).length;
|
||||
export const HiresProgressSection: React.FC<IHiresProgressSectionProps> = ({ linked, vacancies }) => {
|
||||
const { hires, occupied, isLoading, error } = linked;
|
||||
|
||||
const renderHires = (): React.ReactNode => {
|
||||
if (isLoading) return <span className={styles.note}>{TEXT.loading}</span>;
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-2;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-top: $sp-3;
|
||||
padding: $sp-3 $sp-4;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-sm;
|
||||
background: $navy-50;
|
||||
font-family: $font-sans;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: $fs-sm;
|
||||
font-weight: 600;
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: $fs-sm;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: $sp-2;
|
||||
font-size: $fs-sm;
|
||||
color: $amber-700;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from 'react';
|
||||
import styles from './StartHireBar.module.scss';
|
||||
import { Button, Icon } from '../../../shared/ui';
|
||||
import { ILinkedHiresState, allVacanciesOccupied, occupiedLabel } from '../useLinkedHires';
|
||||
|
||||
interface Props {
|
||||
linked: ILinkedHiresState;
|
||||
/** Количество открытых вакансий из 2.2. 0 — не указано. */
|
||||
vacancies: number;
|
||||
onStart: () => void;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
title: 'Приём по этой заявке',
|
||||
occupied: 'занято',
|
||||
button: 'Оформить приём по этой заявке',
|
||||
allOccupied: 'Все вакансии по этой заявке уже заняты — оформляйте, только если это замена или дополнительный приём.'
|
||||
};
|
||||
|
||||
/**
|
||||
* «+ Оформить приём по этой заявке» под вложениями согласованного подбора:
|
||||
* открывает новую заявку на приём, уже связанную с этим подбором и заполненную
|
||||
* из него. Когда вакансии заняты, кнопка не прячется (бывают замены), но рядом
|
||||
* предупреждение. На печать не выводится.
|
||||
*/
|
||||
export const StartHireBar: React.FC<Props> = ({ linked, vacancies, onStart }) => {
|
||||
const { occupied, isLoading, error } = linked;
|
||||
const known = !isLoading && !error;
|
||||
|
||||
return (
|
||||
<div className={`${styles.bar} rp-no-print`}>
|
||||
<div className={styles.head}>
|
||||
<span className={styles.title}>{TEXT.title}</span>
|
||||
{known && (
|
||||
<span className={styles.count}>
|
||||
{TEXT.occupied} {occupiedLabel(occupied, vacancies)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="secondary" onClick={onStart}>
|
||||
<Icon name="plus" size="sm" />
|
||||
{TEXT.button}
|
||||
</Button>
|
||||
</div>
|
||||
{known && allVacanciesOccupied(occupied, vacancies) && (
|
||||
<div className={styles.warning} role="note">
|
||||
<Icon name="warning" size="sm" />
|
||||
<span>{TEXT.allOccupied}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from 'react';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import { getLinkedHires, ILinkedHire, isOccupyingHireStatus } from '../RecruitingHire/linkedHires';
|
||||
|
||||
export interface ILinkedHiresState {
|
||||
hires: ILinkedHire[];
|
||||
/** Сколько приёмов занимают вакансию (то же правило, что в выборе подбора в форме приёма). */
|
||||
occupied: number;
|
||||
isLoading: boolean;
|
||||
/** Текст ошибки загрузки; '' — без ошибки. */
|
||||
error: string;
|
||||
}
|
||||
|
||||
const LOAD_ERROR = 'Не удалось загрузить заявки на приём';
|
||||
|
||||
/** «2 из 6»; без числа вакансий в подборе — просто «2». */
|
||||
export function occupiedLabel(occupied: number, vacancies: number): string {
|
||||
return vacancies > 0 ? `${occupied} из ${vacancies}` : String(occupied);
|
||||
}
|
||||
|
||||
/** Все вакансии заняты — новый приём нужен разве что как замена или дополнительный. */
|
||||
export function allVacanciesOccupied(occupied: number, vacancies: number): boolean {
|
||||
return vacancies > 0 && occupied >= vacancies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Заявки на приём по этому подбору — ОДИН запрос на просмотр подбора: их
|
||||
* показывают и блок «Приём по заявке», и строка «Оформить приём» под вложениями.
|
||||
* enabled=false (подбор ещё не согласован) — ничего не грузим.
|
||||
*/
|
||||
export function useLinkedHires(selectionId: number, selectionListId: string, enabled: boolean): ILinkedHiresState {
|
||||
const [hires, setHires] = React.useState<ILinkedHire[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(enabled);
|
||||
const [error, setError] = React.useState<string>('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let alive = true;
|
||||
setIsLoading(true);
|
||||
getLinkedHires(selectionId, selectionListId)
|
||||
.then((rows) => {
|
||||
if (!alive) return;
|
||||
setHires(rows);
|
||||
setError('');
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setError(`${LOAD_ERROR}: ${friendlyErrorMessage(e)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [selectionId, selectionListId, enabled]);
|
||||
|
||||
return { hires, occupied: hires.filter((h) => isOccupyingHireStatus(h.status)).length, isLoading, error };
|
||||
}
|
||||
@@ -12,11 +12,13 @@
|
||||
Если для вида работ согласование не требуется — заявка сразу «Выполнение».
|
||||
Работу отдел 1С ведёт в карточке Microsoft Planner (см. «Особое поведение»).
|
||||
- Особенности: маршрут условный — набор шагов зависит от полей **«Виды работ»**
|
||||
(`WorkType`) и **«Отдел»** (`Department`, свободный текст из профиля AAD, см.
|
||||
«Особое поведение»), которые передаются в `resolveRoute`/`getPlannedMatrix` через
|
||||
параметр `fieldValues: [{ fieldName: 'WorkType', value: data.WorkType },
|
||||
{ fieldName: 'Department', value: data.Department }]` (см. `IResolveFieldValue` в
|
||||
`shared/approval/models/IApprovalRouteApi.ts`). В админке маршрутов условие по
|
||||
(`WorkType`), **«База 1С»** (`DB1C`) и **«Отдел»** (`Department`, свободный текст
|
||||
из профиля AAD, см. «Особое поведение»), которые передаются в
|
||||
`resolveRoute`/`getPlannedMatrix` через параметр `fieldValues` (см.
|
||||
`IResolveFieldValue` в `shared/approval/models/IApprovalRouteApi.ts`). «База 1С» —
|
||||
мультивыбор, а `value` у API только строка, поэтому все выбранные базы уходят
|
||||
одной записью через пробел; условия по ней в админке — только «содержит»
|
||||
(напр. «База 1С содержит ЗУП»). В админке маршрутов условие по
|
||||
отделу настроено оператором «содержит» (напр. «Department содержит Бухгалтерия»),
|
||||
поэтому важно фактическое значение поля, а не Id из справочника — отдельного
|
||||
DepartmentId у этой формы нет. Для части видов работ маршрут пуст — согласование
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import styles from './Ticket1CForm.module.scss';
|
||||
import {
|
||||
Alert,
|
||||
FieldIssuesProvider,
|
||||
issueLabels,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
@@ -21,7 +23,6 @@ import {
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
loadRoutePhase,
|
||||
isOpenedRouteReady,
|
||||
resolveWriteListId,
|
||||
@@ -31,7 +32,8 @@ import {
|
||||
isEditableStatus,
|
||||
IApproverToWrite,
|
||||
unassignedStepMessage,
|
||||
executorsToWrite
|
||||
executorsToWrite,
|
||||
composeRemoveRowRule
|
||||
} from '../../shared/approval';
|
||||
import {
|
||||
uploadAttachments,
|
||||
@@ -49,7 +51,8 @@ import {
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, hasOtherDatabase } from './model';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { formIssues } from './validation';
|
||||
import { saveTicketRequest, ticket1cApprovalService } from './submit';
|
||||
import { loadTicketRequest } from './load';
|
||||
import { getApplicantProfile } from './services/profile.service';
|
||||
@@ -57,6 +60,7 @@ import { RequestSection } from './sections/RequestSection';
|
||||
import { ContactSection } from './sections/ContactSection';
|
||||
import { useStableCallback } from '../../shared/useStableCallback';
|
||||
import { applyManagerOverride, managerEmail } from './managerOverride';
|
||||
import { planTicket1CPhase, ticket1cRouteFieldValues } from './planRoute';
|
||||
import { handoffToPlanner } from './plannerHandoff';
|
||||
import { submitStatus } from './submitStatus';
|
||||
|
||||
@@ -168,26 +172,24 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||
const requestTypeId = requestTypeIdState.id;
|
||||
|
||||
// Маршрут полностью и однозначно определяется видом работ и отделом — настроен
|
||||
// в админке маршрутов (условные шаги по WorkType и Department, см. fieldValues),
|
||||
// а не выбирается заявителем. Поэтому матрица здесь ВСЕГДА нередактируема
|
||||
// (editable=false) — заявитель только видит, кто согласует, без возможности
|
||||
// добавить/убрать/переставить согласующего.
|
||||
// Условия шагов (и согласования, и исполнения) — по виду работ и отделу.
|
||||
// Маршрут полностью и однозначно определяется видом работ, базой 1С и отделом —
|
||||
// настроен в админке маршрутов (условные шаги по WorkType, DB1C и Department,
|
||||
// см. fieldValues), а не выбирается заявителем. Поэтому матрица здесь ВСЕГДА
|
||||
// нередактируема (editable=false) — заявитель только видит, кто согласует, без
|
||||
// возможности добавить/убрать/переставить согласующего.
|
||||
// Расчёт маршрута вынесен в planRoute.ts — им же пользуется страница
|
||||
// «Проверка маршрутов», чтобы проверять ровно то, что видит заявитель.
|
||||
const fieldValues = useMemo(
|
||||
() => [
|
||||
{ fieldName: 'WorkType', value: data.WorkType },
|
||||
{ fieldName: 'Department', value: data.Department }
|
||||
],
|
||||
[data.WorkType, data.Department]
|
||||
() => ticket1cRouteFieldValues({ WorkType: data.WorkType, DB1C: data.DB1C, Department: data.Department }),
|
||||
[data.WorkType, data.DB1C, data.Department]
|
||||
);
|
||||
const matrixKey = `${userEmail}-${editId ?? 'new'}-${data.WorkType}-${data.Department}`;
|
||||
const matrixKey = [userEmail, editId ?? 'new', ...fieldValues.map((f) => f.value)].join('-');
|
||||
const matrixReady =
|
||||
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.WorkType && !!requestTypeId));
|
||||
const loadMatrix = useCallback(
|
||||
() =>
|
||||
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
|
||||
getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, fieldValues)
|
||||
planTicket1CPhase(context, requestTypeId as number, userEmail, approval, 'Approval', fieldValues)
|
||||
),
|
||||
[context, userEmail, approval, editId, requestStatus, fieldValues, requestTypeId]
|
||||
);
|
||||
@@ -198,7 +200,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
const loadExecutors = useCallback(
|
||||
() =>
|
||||
loadRoutePhase(approval, editId, requestStatus, 'Execution', () =>
|
||||
getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution', null, fieldValues)
|
||||
planTicket1CPhase(context, requestTypeId as number, userEmail, approval, 'Execution', fieldValues)
|
||||
),
|
||||
[context, userEmail, approval, editId, requestStatus, fieldValues, requestTypeId]
|
||||
);
|
||||
@@ -241,20 +243,11 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
);
|
||||
const matrixEditor = useMemo(() => ({ ...matrix, rows: matrixRows }), [matrix, matrixRows]);
|
||||
|
||||
const isOtherDatabaseMissing = hasOtherDatabase(data) && !data.DB1COther.trim();
|
||||
const areRequiredFilled = !!(
|
||||
data.TaskName.trim() &&
|
||||
data.WorkType &&
|
||||
data.Organization &&
|
||||
data.DB1C.length > 0 &&
|
||||
data.Question.trim()
|
||||
);
|
||||
const isValid = areRequiredFilled && !isOtherDatabaseMissing;
|
||||
// Проблемы полей (см. validation.ts) подсвечиваются под полями через FieldIssuesProvider.
|
||||
const issues = formIssues(data);
|
||||
const isValid = issues.length === 0;
|
||||
const validate = (): string | undefined => {
|
||||
if (!areRequiredFilled) {
|
||||
return 'Заполните обязательные поля: наименование задачи, вид работ, организация, база 1С, вопрос.';
|
||||
}
|
||||
if (isOtherDatabaseMissing) return 'Выбрана база 1С «Другое» — укажите её название.';
|
||||
if (!isValid) return `Заполните или исправьте поля: ${issueLabels(issues)}.`;
|
||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||
return undefined;
|
||||
@@ -392,7 +385,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FieldIssuesProvider draftIssues={issues} submitIssues={issues}>
|
||||
<button type="button" className={styles.back} onClick={onCancel}>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
<span>К списку заявок</span>
|
||||
@@ -444,6 +437,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
<ApprovalMatrix
|
||||
editor={matrixEditor}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||
// Пустой маршрут — штатная ситуация: для части видов работ согласование
|
||||
// не нужно, заявка сразу уходит в Planner. Сбой загрузки маршрута
|
||||
// показывается отдельно (matrix.error) и блокирует отправку.
|
||||
@@ -457,6 +451,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
title="Исполнение"
|
||||
roleLabel="Исполнитель"
|
||||
emptyText="Исполнители не назначены."
|
||||
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -485,7 +480,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
submitLabel="Отправить"
|
||||
/>
|
||||
</FormCard>
|
||||
</div>
|
||||
</FieldIssuesProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,13 @@ export const CHOICES_LOADING_PLACEHOLDER = 'Загрузка вариантов
|
||||
export const CHOICES_EMPTY_PLACEHOLDER = 'Нет вариантов — обратитесь к администратору';
|
||||
export const CHOICES_ERROR_PREFIX = 'Не удалось загрузить варианты полей';
|
||||
|
||||
// Подсказка в поле «База 1С», когда сработало правило «дата запрета по АТП».
|
||||
export const DB1C_ATP_ONLY_HINT = 'Для даты запрета по АТП другие базы не выбираются';
|
||||
// Предупреждение над полем «База 1С» при виде работ «Дата запрета».
|
||||
export const DB1C_ATP_ONLY_WARNING =
|
||||
'Для даты запрета (открытие/закрытие) по базе АТП можно выбрать только АТП — ' +
|
||||
'дополнительные базы недоступны.';
|
||||
|
||||
// Значения из property pane старой web part (см. живую страницу SharePoint).
|
||||
export const PLANNER_PLAN_ID = '168WqDV8CEKkW3cL6olCRZgACwYa';
|
||||
export const PLANNER_BUCKET_NAME = 'Входящие неотсортированные';
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IMatrixRow } from '../../shared/approval';
|
||||
import { dedupeRouteRows } from './dedupeRoute';
|
||||
|
||||
function row(order: number, email: string): IMatrixRow {
|
||||
return { order, who: email, email, position: '', status: '—', date: '', comment: '', allowReassign: false };
|
||||
}
|
||||
|
||||
describe('dedupeRouteRows', () => {
|
||||
it('схлопывает одного человека на одном шаге (ЗУП + АТП от финдепа)', () => {
|
||||
const rows = [row(1, 'boss@tnsu.kz'), row(2, 'gulzhan@tnsu.kz'), row(2, 'Gulzhan@tnsu.kz')];
|
||||
expect(dedupeRouteRows(rows).map((r) => r.email)).toEqual(['boss@tnsu.kz', 'gulzhan@tnsu.kz']);
|
||||
});
|
||||
|
||||
it('оставляет разных людей на одном шаге', () => {
|
||||
const rows = [row(2, 'gulzhan@tnsu.kz'), row(2, 'zhanat@tnsu.kz')];
|
||||
expect(dedupeRouteRows(rows)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('оставляет одного человека на разных шагах', () => {
|
||||
const rows = [row(1, 'gulzhan@tnsu.kz'), row(2, 'gulzhan@tnsu.kz')];
|
||||
expect(dedupeRouteRows(rows)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('не трогает неназначенные строки', () => {
|
||||
const rows = [row(2, ''), row(2, '')];
|
||||
expect(dedupeRouteRows(rows)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IMatrixRow } from '../../shared/approval';
|
||||
|
||||
/**
|
||||
* Убирает повторы одного и того же человека внутри одного шага маршрута.
|
||||
*
|
||||
* Зачем: «База 1С» — мультивыбор, и при нескольких базах на одном шаге могут
|
||||
* сработать несколько строк матрицы с одним исполнителем (напр. Гульжан и за
|
||||
* ЗУП, и за АТП от финдепа). Без схлопывания он получил бы две строки
|
||||
* параллельного шага — два уведомления и два решения.
|
||||
*
|
||||
* Один человек на РАЗНЫХ шагах не схлопывается — это разные согласования.
|
||||
* Строки без email (неназначенный шаг) не трогаем.
|
||||
*/
|
||||
export function dedupeRouteRows(rows: IMatrixRow[]): IMatrixRow[] {
|
||||
const seen = new Set<string>();
|
||||
return rows.filter((row) => {
|
||||
if (!row.email) return true;
|
||||
const key = `${row.order}|${row.email.toLowerCase()}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { DB1C_ATP_OPTION, WORK_TYPE_BAN_DATE, restrictDatabases } from './model';
|
||||
|
||||
const ZUP = '1С: Зарплата и управление персоналом (ЗУП)';
|
||||
|
||||
describe('Ticket1C restrictDatabases', () => {
|
||||
it('дата запрета + АТП — остаётся только АТП', () => {
|
||||
expect(restrictDatabases(WORK_TYPE_BAN_DATE, [ZUP, DB1C_ATP_OPTION])).toEqual([DB1C_ATP_OPTION]);
|
||||
});
|
||||
|
||||
it('дата запрета без АТП — базы не трогаем', () => {
|
||||
expect(restrictDatabases(WORK_TYPE_BAN_DATE, [ZUP])).toEqual([ZUP]);
|
||||
});
|
||||
|
||||
it('другой вид работ — АТП можно сочетать с другими базами', () => {
|
||||
expect(restrictDatabases('Прочее', [ZUP, DB1C_ATP_OPTION])).toEqual([ZUP, DB1C_ATP_OPTION]);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,27 @@ export function hasOtherDatabase(data: IFormData): boolean {
|
||||
return data.DB1C.indexOf(DB1C_OTHER_OPTION) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Варианты колонок SP, для которых действует правило «дата запрета по АТП —
|
||||
* только одна база». Должны дословно совпадать с вариантами колонок
|
||||
* WorkType и DB1C в SharePoint (варианты читаются из SP, см. useFieldChoices).
|
||||
*/
|
||||
export const WORK_TYPE_BAN_DATE = 'Дата запрета (открытие/закрытие)';
|
||||
export const DB1C_ATP_OPTION = '1С: Автотранспортное предприятие (АТП)';
|
||||
|
||||
/**
|
||||
* Дата запрета в АТП открывается/закрывается отдельно от остальных баз,
|
||||
* поэтому в такой заявке база АТП может быть только одна.
|
||||
*/
|
||||
export function isAtpOnlyBanDate(workType: string, databases: string[]): boolean {
|
||||
return workType === WORK_TYPE_BAN_DATE && databases.indexOf(DB1C_ATP_OPTION) !== -1;
|
||||
}
|
||||
|
||||
/** Применяет правило isAtpOnlyBanDate: при его срабатывании оставляет только АТП. */
|
||||
export function restrictDatabases(workType: string, databases: string[]): string[] {
|
||||
return isAtpOnlyBanDate(workType, databases) ? [DB1C_ATP_OPTION] : databases;
|
||||
}
|
||||
|
||||
/** Обновление одного поля формы с сохранением типа значения. */
|
||||
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import {
|
||||
ApprovalListService,
|
||||
ApprovalStatus,
|
||||
getPlannedMatrix,
|
||||
IMatrixRow,
|
||||
IResolveFieldValue
|
||||
} from '../../shared/approval';
|
||||
import { IFormData } from './model';
|
||||
import { dedupeRouteRows } from './dedupeRoute';
|
||||
|
||||
/** Поля заявки, от которых зависит маршрут 1С (условия шагов в админке маршрута #8). */
|
||||
export type Ticket1CRouteInput = Pick<IFormData, 'WorkType' | 'DB1C' | 'Department'>;
|
||||
|
||||
/**
|
||||
* Значения полей для условных шагов маршрута (fieldValues при resolve).
|
||||
*
|
||||
* Базы 1С — мультивыбор, а approval-route принимает value только строкой:
|
||||
* все выбранные базы уходят одной записью через пробел (как CompaniesJson в
|
||||
* RecruitingHire), условия в админке — «DB1C содержит ЗУП/АТП/ATS».
|
||||
*/
|
||||
export function ticket1cRouteFieldValues(input: Ticket1CRouteInput): IResolveFieldValue[] {
|
||||
return [
|
||||
{ fieldName: 'WorkType', value: input.WorkType },
|
||||
{ fieldName: 'DB1C', value: input.DB1C.slice().sort().join(' ') },
|
||||
{ fieldName: 'Department', value: input.Department }
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Запланированный маршрут одной фазы заявки в 1С. Один источник для формы и для
|
||||
* страницы «Проверка маршрутов» — проверка должна видеть ровно то, что увидит
|
||||
* пользователь, включая схлопывание повторов на шаге.
|
||||
*/
|
||||
export async function planTicket1CPhase(
|
||||
context: WebPartContext,
|
||||
requestTypeId: number,
|
||||
userEmail: string,
|
||||
approval: ApprovalListService,
|
||||
phase: ApprovalStatus,
|
||||
fieldValues: IResolveFieldValue[]
|
||||
): Promise<IMatrixRow[]> {
|
||||
const rows = await getPlannedMatrix(context, requestTypeId, userEmail, approval, phase, null, fieldValues);
|
||||
return dedupeRouteRows(rows);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Строка на обе колонки сетки секции — как FormField с full.
|
||||
.fullRow {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -1,10 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RequestSection.module.scss';
|
||||
import { Alert, CollapseSection, FormField, MultiChoiceField, Select, TextArea, TextInput, ISelectOption } from '../../../shared/ui';
|
||||
import { useFieldChoices, IFieldChoicesState } from '../../../shared/fieldChoices';
|
||||
import { dataListTitle } from '../../../shared/listTitles';
|
||||
import { DB1C_OTHER_OPTION, FORM_ID, ISectionProps, hasOtherDatabase } from '../model';
|
||||
import {
|
||||
DB1C_OTHER_OPTION,
|
||||
FORM_ID,
|
||||
WORK_TYPE_BAN_DATE,
|
||||
ISectionProps,
|
||||
hasOtherDatabase,
|
||||
isAtpOnlyBanDate,
|
||||
restrictDatabases
|
||||
} from '../model';
|
||||
import {
|
||||
QUESTION_MAX,
|
||||
DB1C_ATP_ONLY_HINT,
|
||||
DB1C_ATP_ONLY_WARNING,
|
||||
CHOICES_LOADING_PLACEHOLDER,
|
||||
CHOICES_EMPTY_PLACEHOLDER,
|
||||
CHOICES_ERROR_PREFIX
|
||||
@@ -30,6 +41,16 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => {
|
||||
const organizations = useFieldChoices(listTitle, 'Organization');
|
||||
const databases = useFieldChoices(listTitle, 'DB1C');
|
||||
const choicesError = workTypes.error || organizations.error || databases.error;
|
||||
const isAtpOnly = isAtpOnlyBanDate(data.WorkType, data.DB1C);
|
||||
|
||||
// Правило «дата запрета по АТП» зависит и от вида работ, и от баз — поэтому
|
||||
// применяется при смене любого из двух полей.
|
||||
const setDatabases = (workType: string, values: string[]): void => {
|
||||
const restricted = restrictDatabases(workType, values);
|
||||
set('DB1C', restricted);
|
||||
// Сняли «Другое» — вписанное название больше не относится к заявке.
|
||||
if (restricted.indexOf(DB1C_OTHER_OPTION) === -1) set('DB1COther', '');
|
||||
};
|
||||
|
||||
return (
|
||||
<CollapseSection title="Заявка" icon="clipboard">
|
||||
@@ -41,7 +62,10 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => {
|
||||
<Select
|
||||
id="WorkType"
|
||||
value={data.WorkType}
|
||||
onChange={(v) => set('WorkType', v)}
|
||||
onChange={(v) => {
|
||||
set('WorkType', v);
|
||||
setDatabases(v, data.DB1C);
|
||||
}}
|
||||
options={withCurrentValue(workTypes.options, data.WorkType)}
|
||||
placeholder={choicesPlaceholder(workTypes, '— выберите —')}
|
||||
/>
|
||||
@@ -55,18 +79,22 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => {
|
||||
placeholder={choicesPlaceholder(organizations, '— выберите —')}
|
||||
/>
|
||||
</FormField>
|
||||
{/* Предупреждаем заранее, ещё до выбора баз, чтобы правило не стало сюрпризом. */}
|
||||
{data.WorkType === WORK_TYPE_BAN_DATE && (
|
||||
<div className={styles.fullRow}>
|
||||
<Alert variant="warn">{DB1C_ATP_ONLY_WARNING}</Alert>
|
||||
</div>
|
||||
)}
|
||||
{/* На всю ширину: чипы выбранных баз растят поле по высоте, и соседнее
|
||||
поле в той же строке сетки съезжало бы относительно него. */}
|
||||
<FormField label="База 1С" fieldKey="DB1C" required full>
|
||||
<MultiChoiceField
|
||||
value={data.DB1C}
|
||||
onChange={(v) => {
|
||||
set('DB1C', v);
|
||||
// Сняли «Другое» — вписанное название больше не относится к заявке.
|
||||
if (v.indexOf(DB1C_OTHER_OPTION) === -1) set('DB1COther', '');
|
||||
}}
|
||||
options={databases.options}
|
||||
placeholder={choicesPlaceholder(databases, 'Выберите одну или несколько баз')}
|
||||
onChange={(v) => setDatabases(data.WorkType, v)}
|
||||
// Пустой список вариантов прячет выпадашку — добавить другую базу нельзя,
|
||||
// пока не уберут АТП или не сменят вид работ.
|
||||
options={isAtpOnly ? [] : databases.options}
|
||||
placeholder={isAtpOnly ? DB1C_ATP_ONLY_HINT : choicesPlaceholder(databases, 'Выберите одну или несколько баз')}
|
||||
/>
|
||||
</FormField>
|
||||
{hasOtherDatabase(data) && (
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { IFieldIssue } from '../../shared/ui';
|
||||
import { IFormData, hasOtherDatabase } from './model';
|
||||
|
||||
/** Название базы вручную обязательно, только если в «База 1С» отмечено «Другое». */
|
||||
function otherDatabaseIssue(data: IFormData): IFieldIssue | undefined {
|
||||
if (!hasOtherDatabase(data) || data.DB1COther.trim()) return undefined;
|
||||
return { field: 'DB1COther', label: 'Другая база 1С', message: 'выбрано «Другое» — укажите название базы' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Всё, что мешает сохранить или отправить заявку, в порядке формы (пусто — можно).
|
||||
* field — ключ FormField (htmlFor/fieldKey): по нему поле подсвечивается и к нему
|
||||
* прокручивается форма.
|
||||
*/
|
||||
export function formIssues(data: IFormData): IFieldIssue[] {
|
||||
const issues: Array<IFieldIssue | undefined> = [
|
||||
data.TaskName.trim() ? undefined : { field: 'TaskName', label: 'Наименование задачи' },
|
||||
data.WorkType ? undefined : { field: 'WorkType', label: 'Виды работ' },
|
||||
data.Organization ? undefined : { field: 'Organization', label: 'Организация' },
|
||||
data.DB1C.length > 0 ? undefined : { field: 'DB1C', label: 'База 1С' },
|
||||
otherDatabaseIssue(data),
|
||||
data.Question.trim() ? undefined : { field: 'Question', label: 'Вопрос' }
|
||||
];
|
||||
return issues.filter((issue): issue is IFieldIssue => issue !== undefined);
|
||||
}
|
||||
@@ -22,10 +22,11 @@
|
||||
- **Период вводится двумя датами** (с / по), обе обязательны, конец ≥ начала.
|
||||
`DaysCount` считается при сохранении (включительно) для отчётности — в UI не показывается.
|
||||
- **Причина** (`UnpaidLeaveReasonType`, Choice) — обязательна: брак / рождение ребёнка /
|
||||
смерть близких / «В иных случаях». Только при «иных случаях» появляется обязательный
|
||||
смерть близких / «В иных случаях» (в форме подписано «Иная причина», значение в SP прежнее). Только при «иных случаях» появляется обязательный
|
||||
текст причины (`Reason`). У заявок до появления колонки причины нет — их прежний
|
||||
текст из `Reason` по-прежнему показывается (`isReasonTextShown`).
|
||||
- **Не более 5 календарных дней** (`MAX_DAYS`) — при любой причине.
|
||||
- **Количество дней без верхнего предела** — 5 дней работодатель обязан дать при браке /
|
||||
рождении / смерти, остальное — по договорённости, поэтому форма лимит не ставит.
|
||||
- **Правило «не позднее чем за 14 дней»** (`MIN_NOTICE_DAYS`) — только для «иных случаев»
|
||||
(`requiresAdvanceNotice`): брак, рождение и смерть заранее не планируются.
|
||||
- **Подсветка проблемных полей** — `FieldIssuesProvider` + правила в `validation.ts`
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
RequestNumber,
|
||||
SubmitActions,
|
||||
getDraftDeleteHandler,
|
||||
useRequestSubmit
|
||||
useRequestSubmit,
|
||||
LeaveNotice
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
composeRemoveRowRule,
|
||||
@@ -45,7 +46,8 @@ import {
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
|
||||
import { organizationRouteFieldValues } from '../../shared/dictionary/organizationRouteFields';
|
||||
import { formIssues } from './validation';
|
||||
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
|
||||
import { loadUnpaidLeaveRequest } from './load';
|
||||
@@ -161,16 +163,22 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
||||
// Планируемый маршрут резолвится по e-mail ВЫБРАННОГО сотрудника (не текущего
|
||||
// пользователя) — так работает подача за другого. У заявки на редактировании
|
||||
// маршрут читается из уже записанных строк, чтобы не затереть правки инициатора.
|
||||
//
|
||||
// Кадровый сотрудник и исполнитель зависят от организации сотрудника (условия
|
||||
// «Organization содержит …» в админке) — см. shared/dictionary/organizationRouteFields.ts.
|
||||
// Организация приходит позже выбора сотрудника, поэтому она в ключе перезагрузки.
|
||||
const routeFieldValues = useMemo(() => organizationRouteFieldValues(data.Organization), [data.Organization]);
|
||||
const routeOrgKey = routeFieldValues.map((f) => f.value).join('|');
|
||||
const loadMatrix = useCallback(
|
||||
() =>
|
||||
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
|
||||
getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval')
|
||||
getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, routeFieldValues)
|
||||
),
|
||||
[context, approval, editId, requestStatus, data.Employee, requestTypeId]
|
||||
[context, approval, editId, requestStatus, data.Employee, requestTypeId, routeFieldValues]
|
||||
);
|
||||
const matrix = useMatrixEditor(
|
||||
loadMatrix,
|
||||
`${data.Employee}-${editId ?? 'new'}`,
|
||||
`${data.Employee}-${editId ?? 'new'}-${routeOrgKey}`,
|
||||
!readOnly,
|
||||
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.Employee && !!requestTypeId)),
|
||||
(login) => approval.getJobTitle(login)
|
||||
@@ -184,7 +192,8 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
||||
requestTypeId,
|
||||
userEmail: data.Employee,
|
||||
editId,
|
||||
requestStatus
|
||||
requestStatus,
|
||||
fieldValues: routeFieldValues
|
||||
});
|
||||
|
||||
// Проблемы полей (см. validation.ts) подсвечиваются под полями через FieldIssuesProvider.
|
||||
@@ -326,6 +335,8 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
||||
)}
|
||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||
|
||||
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||
|
||||
<FormModeProvider readOnly={readOnly}>
|
||||
<RequestSection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
@@ -355,6 +366,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
isValid={isValid}
|
||||
formId={FORM_ID}
|
||||
readOnly={readOnly}
|
||||
onCancel={onCancel}
|
||||
onSaveDraft={handleSaveDraft}
|
||||
|
||||
@@ -2,7 +2,15 @@ import * as React from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './UnpaidLeaveView.module.scss';
|
||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
||||
import {
|
||||
Alert,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
PrintSplitButton,
|
||||
OrgLetterhead,
|
||||
LeaveNotice
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
DecisionPanel,
|
||||
@@ -23,7 +31,7 @@ import {
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
|
||||
import { loadUnpaidLeaveRequest } from './load';
|
||||
import { unpaidLeaveApprovalService } from './submit';
|
||||
import { RequestSection } from './sections/RequestSection';
|
||||
@@ -148,6 +156,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
||||
}
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||
<FormModeProvider readOnly>
|
||||
<RequestSection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
|
||||
@@ -94,8 +94,13 @@ export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О.
|
||||
* Действует только для «иных случаев» — см. requiresAdvanceNotice.
|
||||
*/
|
||||
export const MIN_NOTICE_DAYS = 14;
|
||||
/** Отпуск без сохранения заработной платы — не более стольких календарных дней (по любой причине). */
|
||||
export const MAX_DAYS = 5;
|
||||
|
||||
/** Памятка вверху формы и просмотра (см. shared/ui/LeaveNotice). Текст — по просьбе HR. */
|
||||
export const NOTICE_TITLE = 'Памятка';
|
||||
export const NOTICE_ITEMS: string[] = [
|
||||
'После согласования заявления его необходимо распечатать с портала (кнопка «Печать» на странице заявки), ' +
|
||||
'собственноручно подписать и передать специалисту отдела кадров.'
|
||||
];
|
||||
|
||||
/** Причины отпуска. Значения совпадают с choices колонки UnpaidLeaveReasonType. */
|
||||
export const REASON_MARRIAGE = 'Регистрация брака';
|
||||
@@ -108,7 +113,8 @@ export const UNPAID_LEAVE_REASONS: ISelectOption[] = [
|
||||
{ value: REASON_MARRIAGE, label: REASON_MARRIAGE },
|
||||
{ value: REASON_CHILD_BIRTH, label: REASON_CHILD_BIRTH },
|
||||
{ value: REASON_RELATIVE_DEATH, label: REASON_RELATIVE_DEATH },
|
||||
{ value: REASON_OTHER, label: REASON_OTHER }
|
||||
// Значение в SP остаётся прежним (choices колонки и старые заявки), меняется только подпись.
|
||||
{ value: REASON_OTHER, label: 'Иная причина' }
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
EMPLOYEE_PLACEHOLDER,
|
||||
SUBSTITUTE_PLACEHOLDER,
|
||||
MIN_NOTICE_DAYS,
|
||||
MAX_DAYS,
|
||||
REASON_OTHER,
|
||||
UNPAID_LEAVE_REASONS,
|
||||
requiresAdvanceNotice,
|
||||
@@ -61,14 +60,13 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
|
||||
min={requiresAdvanceNotice(data.ReasonType) ? isoDatePlusDays(MIN_NOTICE_DAYS) : undefined}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={`Количество дней (не более ${MAX_DAYS})`} htmlFor="DaysCount" required>
|
||||
<FormField label="Количество дней" htmlFor="DaysCount" required>
|
||||
<TextInput
|
||||
id="DaysCount"
|
||||
type="number"
|
||||
value={data.DaysCount}
|
||||
onChange={(v) => set('DaysCount', v)}
|
||||
min={1}
|
||||
max={MAX_DAYS}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Причина" htmlFor="UnpaidLeaveReasonType" required full>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IFieldIssue } from '../../shared/ui';
|
||||
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
|
||||
import { IFormData, MAX_DAYS, MIN_NOTICE_DAYS, REASON_OTHER, requiresAdvanceNotice } from './model';
|
||||
import { IFormData, MIN_NOTICE_DAYS, REASON_OTHER, requiresAdvanceNotice } from './model';
|
||||
|
||||
/** Дата начала: обязательна, а в «иных случаях» — не раньше чем через MIN_NOTICE_DAYS дней. */
|
||||
function startDateIssue(data: IFormData): IFieldIssue | undefined {
|
||||
@@ -12,17 +12,20 @@ function startDateIssue(data: IFormData): IFieldIssue | undefined {
|
||||
return {
|
||||
field: 'StartDate',
|
||||
label: 'Дата начала',
|
||||
message: `в иных случаях заявление подают не позднее чем за ${MIN_NOTICE_DAYS} дней — не раньше ${formatIsoDate(minStartDate)}`
|
||||
message: `при иной причине заявление подают не позднее чем за ${MIN_NOTICE_DAYS} дней — не раньше ${formatIsoDate(minStartDate)}`
|
||||
};
|
||||
}
|
||||
|
||||
/** Количество дней: обязательно, от 1 до MAX_DAYS. */
|
||||
/**
|
||||
* Количество дней: обязательно, целое и больше нуля. Верхнего предела нет:
|
||||
* сверх 5 дней (обязательных при браке/рождении/смерти) — по договорённости с работодателем.
|
||||
*/
|
||||
function daysCountIssue(data: IFormData): IFieldIssue | undefined {
|
||||
if (!data.DaysCount.trim()) return { field: 'DaysCount', label: 'Количество дней' };
|
||||
|
||||
const days = Number(data.DaysCount);
|
||||
if (Number.isFinite(days) && days > 0 && days <= MAX_DAYS) return undefined;
|
||||
return { field: 'DaysCount', label: 'Количество дней', message: `от 1 до ${MAX_DAYS} календарных дней` };
|
||||
if (Number.isInteger(days) && days > 0) return undefined;
|
||||
return { field: 'DaysCount', label: 'Количество дней', message: 'целое число больше нуля' };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
- **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`.
|
||||
|
||||
## Подписание в ЭДО
|
||||
> **Сейчас выключено** (`VACATION_EDO_SIGNING_ENABLED = false` в `shared/config.ts`): решение последнего
|
||||
> согласующего применяется сразу, в ЭДО ничего не уходит. Описанное ниже работает, когда флаг `true`.
|
||||
|
||||
Когда **последний согласующий** нажимает «Согласовать», заявление сначала уходит на подпись в TANSU ЭДО
|
||||
(`VacationView` → `onBeforeDecide`), и только потом применяется решение. Последний — значит заявка
|
||||
«На согласовании» и несогласованной осталась одна строка фазы согласования (`completesApprovalPhase`;
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import styles from './VacationForm.module.scss';
|
||||
import {
|
||||
Alert,
|
||||
FieldIssuesProvider,
|
||||
issueLabels,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
@@ -10,7 +12,8 @@ import {
|
||||
RequestNumber,
|
||||
SubmitActions,
|
||||
getDraftDeleteHandler,
|
||||
useRequestSubmit
|
||||
useRequestSubmit,
|
||||
LeaveNotice
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
composeRemoveRowRule,
|
||||
@@ -37,14 +40,15 @@ import { effectiveUserEmail } from '../../shared/impersonation';
|
||||
import { IRequestFormProps } from '../../models/types';
|
||||
import { getEmployeeOrganizations, formatEmployeeOrganizationsWithDepartment, IEmployeeOrganization } from '../../shared/dictionary';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
|
||||
import {
|
||||
approvalListTitle,
|
||||
approvalListTitleForListId,
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_DAYS } from './model';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
|
||||
import { formIssues } from './validation';
|
||||
import { organizationRouteFieldValues } from '../../shared/dictionary/organizationRouteFields';
|
||||
import { saveVacationRequest, vacationApprovalService } from './submit';
|
||||
import { loadVacationRequest } from './load';
|
||||
import { RequestSection } from './sections/RequestSection';
|
||||
@@ -169,16 +173,24 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
// Планируемый маршрут резолвится по e-mail ВЫБРАННОГО сотрудника (не текущего
|
||||
// пользователя) — так работает подача за другого. У заявки на редактировании
|
||||
// маршрут читается из уже записанных строк, чтобы не затереть правки инициатора.
|
||||
//
|
||||
// Кадровый сотрудник и исполнитель в маршруте зависят от организации
|
||||
// сотрудника (условия «Organization содержит …» в админке) — см.
|
||||
// shared/dictionary/organizationRouteFields.ts. Организация приходит позже
|
||||
// выбора сотрудника (Employee/ByEmail), поэтому она в ключе перезагрузки:
|
||||
// маршрут пересчитается, когда придёт, а не останется рассчитанным без условий.
|
||||
const routeFieldValues = useMemo(() => organizationRouteFieldValues(data.Organization), [data.Organization]);
|
||||
const routeOrgKey = routeFieldValues.map((f) => f.value).join('|');
|
||||
const loadMatrix = useCallback(
|
||||
() =>
|
||||
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
|
||||
getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval')
|
||||
getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, routeFieldValues)
|
||||
),
|
||||
[context, approval, editId, requestStatus, data.Employee, requestTypeId]
|
||||
[context, approval, editId, requestStatus, data.Employee, requestTypeId, routeFieldValues]
|
||||
);
|
||||
const matrix = useMatrixEditor(
|
||||
loadMatrix,
|
||||
`${data.Employee}-${editId ?? 'new'}`,
|
||||
`${data.Employee}-${editId ?? 'new'}-${routeOrgKey}`,
|
||||
!readOnly,
|
||||
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.Employee && !!requestTypeId)),
|
||||
(login) => approval.getJobTitle(login)
|
||||
@@ -193,27 +205,15 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
requestTypeId,
|
||||
userEmail: data.Employee,
|
||||
editId,
|
||||
requestStatus
|
||||
requestStatus,
|
||||
fieldValues: routeFieldValues
|
||||
});
|
||||
|
||||
const daysNum = Number(data.DaysCount);
|
||||
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
|
||||
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт
|
||||
// выбрать более раннюю дату, но проверяем и здесь на случай обхода UI.
|
||||
const minStartDate = isoDatePlusDays(MIN_NOTICE_DAYS);
|
||||
const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate;
|
||||
const isValid = !!(
|
||||
data.Employee &&
|
||||
data.StartDate &&
|
||||
isStartDateValid &&
|
||||
Number.isFinite(daysNum) &&
|
||||
daysNum > 0
|
||||
);
|
||||
// Проблемы полей (см. validation.ts) подсвечиваются под полями через FieldIssuesProvider.
|
||||
const issues = formIssues(data);
|
||||
const isValid = issues.length === 0;
|
||||
const validate = (): string | undefined => {
|
||||
if (data.StartDate && !isStartDateValid) {
|
||||
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
|
||||
}
|
||||
if (!isValid) return 'Заполните обязательные поля: сотрудник, дата начала, количество дней (> 0).';
|
||||
if (!isValid) return `Заполните или исправьте поля: ${issueLabels(issues)}.`;
|
||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||
return undefined;
|
||||
@@ -323,7 +323,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FieldIssuesProvider draftIssues={issues} submitIssues={issues}>
|
||||
<button type="button" className={styles.back} onClick={onCancel}>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
<span>К списку заявок</span>
|
||||
@@ -354,6 +354,8 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
)}
|
||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||
|
||||
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||
|
||||
<FormModeProvider readOnly={readOnly}>
|
||||
<RequestSection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
@@ -383,6 +385,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
isValid={isValid}
|
||||
formId={FORM_ID}
|
||||
readOnly={readOnly}
|
||||
onCancel={onCancel}
|
||||
onSaveDraft={handleSaveDraft}
|
||||
@@ -390,7 +393,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
submitLabel="Отправить на согласование"
|
||||
/>
|
||||
</FormCard>
|
||||
</div>
|
||||
</FieldIssuesProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,16 @@ import * as React from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './VacationView.module.scss';
|
||||
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
PrintSplitButton,
|
||||
OrgLetterhead,
|
||||
LeaveNotice
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
DecisionPanel,
|
||||
@@ -21,13 +30,14 @@ import type { ApproverDecision } from '../../shared/approval';
|
||||
import { isAlreadyAccepted } from '../../shared/edo/edoErrors';
|
||||
import { executionPhaseRows } from '../../shared/approval/useRouteExecutors';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import { VACATION_EDO_SIGNING_ENABLED } from '../../shared/config';
|
||||
import {
|
||||
approvalListTitle,
|
||||
approvalListTitleForListId,
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
|
||||
import { loadVacationRequest } from './load';
|
||||
import { vacationApprovalService } from './submit';
|
||||
import { IVacationSigningResult, sendVacationForSigning } from './sendToSign';
|
||||
@@ -170,7 +180,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
||||
// заявка остаётся на согласовании, и ошибку видно в панели решения; согласовать можно ещё раз.
|
||||
const sendBeforeFinalApproval = async (decision: ApproverDecision): Promise<void> => {
|
||||
sentBeforeDecision.current = false;
|
||||
if (decision !== 'approve') return;
|
||||
if (!VACATION_EDO_SIGNING_ENABLED || decision !== 'approve') return;
|
||||
|
||||
if (!completesApprovalPhase(status, await approval.getMatrixForRequest(itemId))) return;
|
||||
|
||||
@@ -208,6 +218,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
||||
}
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||
<FormModeProvider readOnly>
|
||||
<RequestSection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
@@ -225,6 +236,9 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
{/* Пока отправка в ЭДО выключена — ни одного сообщения о ней. */}
|
||||
{VACATION_EDO_SIGNING_ENABLED && (
|
||||
<>
|
||||
{signingBusy && <Alert variant="ok">Отправляем заявление на подпись в ЭДО…</Alert>}
|
||||
{!signingBusy && signingResult && signingResult.skipped.length > 0 && (
|
||||
<Alert variant="err">
|
||||
@@ -239,6 +253,8 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
requestId={itemId}
|
||||
@@ -248,7 +264,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
return sentBeforeDecision.current ? 'Документы отправлены на подписание в ЭДО.' : undefined;
|
||||
return VACATION_EDO_SIGNING_ENABLED && sentBeforeDecision.current ?'Документы отправлены на подписание в ЭДО.' : undefined;
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -79,6 +79,25 @@ export const EMPTY_DATA: IFormData = {
|
||||
/** Плейсхолдер для поля выбора сотрудника. */
|
||||
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
|
||||
/** Заявку нужно подавать не позднее чем за столько дней до даты начала отпуска. */
|
||||
export const MIN_NOTICE_DAYS = 14;
|
||||
export const MIN_NOTICE_DAYS = 7;
|
||||
/** Ежегодный трудовой отпуск — не более стольких календарных дней в одной заявке (требование HR). */
|
||||
export const MAX_DAYS = 24;
|
||||
|
||||
/**
|
||||
* Примечание вверху формы и просмотра (см. shared/ui/LeaveNotice). Текст — от HR,
|
||||
* дословно. «Одну календарную неделю» — это MIN_NOTICE_DAYS: поменяли срок — поправьте и текст.
|
||||
* Правило про часть не менее 14 дней только показываем, а не проверяем: форма не знает,
|
||||
* брал ли сотрудник уже часть отпуска.
|
||||
*/
|
||||
export const NOTICE_TITLE = 'Примечание';
|
||||
export const NOTICE_ITEMS: string[] = [
|
||||
'Согласно пункту 3 статьи 92 Трудового кодекса Республики Казахстан, оплачиваемый ежегодный трудовой отпуск ' +
|
||||
'может быть разделен на части по соглашению сторон, но одна из частей должна составлять не менее 14 календарных дней.',
|
||||
'Заявление на оплачиваемый ежегодный трудовой отпуск направляется не менее чем за одну календарную неделю ' +
|
||||
'до начала отпуска.',
|
||||
'После согласования заявления на оплачиваемый ежегодный трудовой отпуск по маршруту на портале сотрудник ' +
|
||||
'должен подойти в отдел кадров для подписания заявления на бумажном носителе.'
|
||||
];
|
||||
|
||||
/** Плейсхолдер для поля выбора замещающего. */
|
||||
export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О. замещающего…';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { FormGrid, FormField, TextInput, PeopleField, OrganizationTable } from '../../../shared/ui';
|
||||
import { ProjectPicker } from '../../../shared/dictionary';
|
||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
|
||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS, MAX_DAYS } from '../model';
|
||||
import { isoDatePlusDays } from '../../../shared/dateUtils';
|
||||
|
||||
/** Единственная секция формы «Заявка на отпуск»: сотрудник (должность и подразделение подтягиваются из справочника), даты. */
|
||||
@@ -38,7 +38,15 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Количество дней" htmlFor="DaysCount" required>
|
||||
<TextInput id="DaysCount" type="number" value={data.DaysCount} onChange={(v) => set('DaysCount', v)} />
|
||||
<TextInput
|
||||
id="DaysCount"
|
||||
type="number"
|
||||
value={data.DaysCount}
|
||||
onChange={(v) => set('DaysCount', v)}
|
||||
min={1}
|
||||
max={MAX_DAYS}
|
||||
placeholder={`от 1 до ${MAX_DAYS}`}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Лицо, замещающее на время отсутствия" htmlFor="Substitute" full>
|
||||
<PeopleField
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { IFieldIssue } from '../../shared/ui';
|
||||
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
|
||||
import { IFormData, MAX_DAYS, MIN_NOTICE_DAYS } from './model';
|
||||
|
||||
/**
|
||||
* Дата начала: обязательна и не раньше чем через MIN_NOTICE_DAYS дней. Календарь
|
||||
* (min у input type=date, см. RequestSection) раннюю дату выбрать не даст,
|
||||
* но её можно вписать с клавиатуры — поэтому проверяем и здесь.
|
||||
*/
|
||||
function startDateIssue(data: IFormData): IFieldIssue | undefined {
|
||||
if (!data.StartDate) return { field: 'StartDate', label: 'Дата начала' };
|
||||
|
||||
const minStartDate = isoDatePlusDays(MIN_NOTICE_DAYS);
|
||||
if (data.StartDate >= minStartDate) return undefined;
|
||||
return {
|
||||
field: 'StartDate',
|
||||
label: 'Дата начала',
|
||||
message: `заявление подают не позднее чем за ${MIN_NOTICE_DAYS} дней — не раньше ${formatIsoDate(minStartDate)}`
|
||||
};
|
||||
}
|
||||
|
||||
/** Количество дней: обязательно, целое, от 1 до MAX_DAYS. */
|
||||
function daysCountIssue(data: IFormData): IFieldIssue | undefined {
|
||||
if (!data.DaysCount.trim()) return { field: 'DaysCount', label: 'Количество дней' };
|
||||
|
||||
const days = Number(data.DaysCount);
|
||||
if (Number.isInteger(days) && days >= 1 && days <= MAX_DAYS) return undefined;
|
||||
return { field: 'DaysCount', label: 'Количество дней', message: `от 1 до ${MAX_DAYS} календарных дней` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Всё, что мешает сохранить или отправить заявку, в порядке формы (пусто — можно).
|
||||
* field — ключ FormField (htmlFor): по нему поле подсвечивается и к нему
|
||||
* прокручивается форма.
|
||||
*/
|
||||
export function formIssues(data: IFormData): IFieldIssue[] {
|
||||
const issues: Array<IFieldIssue | undefined> = [
|
||||
data.Employee ? undefined : { field: 'Employee', label: 'Сотрудник' },
|
||||
startDateIssue(data),
|
||||
daysCountIssue(data)
|
||||
];
|
||||
return issues.filter((issue): issue is IFieldIssue => issue !== undefined);
|
||||
}
|
||||
@@ -55,6 +55,9 @@ const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
|
||||
'hr-transfer': React.lazy(
|
||||
() => import(/* webpackChunkName: 'form-employee-transfer' */ './EmployeeTransfer/EmployeeTransferForm')
|
||||
),
|
||||
'acc-payment-request': React.lazy(
|
||||
() => import(/* webpackChunkName: 'form-payment-request' */ './PaymentRequest/PaymentRequestForm')
|
||||
),
|
||||
'wh-concrete-request': React.lazy(
|
||||
() => import(/* webpackChunkName: 'form-concrete-request' */ './ConcreteRequest/ConcreteRequestForm')
|
||||
)
|
||||
|
||||
@@ -74,6 +74,9 @@ const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
|
||||
'hr-transfer': React.lazy(
|
||||
() => import(/* webpackChunkName: 'view-employee-transfer' */ './EmployeeTransfer/EmployeeTransferView')
|
||||
),
|
||||
'acc-payment-request': React.lazy(
|
||||
() => import(/* webpackChunkName: 'view-payment-request' */ './PaymentRequest/PaymentRequestView')
|
||||
),
|
||||
'wh-concrete-request': React.lazy(
|
||||
() => import(/* webpackChunkName: 'view-concrete-request' */ './ConcreteRequest/ConcreteRequestView')
|
||||
)
|
||||
|
||||
@@ -39,6 +39,12 @@ export interface IRequestFormProps {
|
||||
* Формы, не поддерживающие производные заявки, игнорируют.
|
||||
*/
|
||||
sourceId?: number;
|
||||
/**
|
||||
* Партиция (серверный url списка), где лежит sourceId — заявки нумерованные,
|
||||
* Id в разных партициях повторяются (см. shared/approval/listPartitions.ts).
|
||||
* Не задан — исходная заявка ищется в текущей партиции.
|
||||
*/
|
||||
sourceListId?: string;
|
||||
/**
|
||||
* Параметры внешней ссылки (Dataverse «Строка заявки» + справочник
|
||||
* номенклатуры). Использует только форма «СЗ на перемещение ТМЦ»: по
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
|
||||
|
||||
/** JSON-схема ответа в формате, который принимает прокси Gemini (как в СТ). */
|
||||
export interface IGeminiSchema {
|
||||
type: 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean';
|
||||
properties?: Record<string, IGeminiSchema>;
|
||||
items?: IGeminiSchema;
|
||||
required?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /Proxy/Gemini { prompt, responseSchema } → объект, подогнанный под схему.
|
||||
* Тот же вызов делает СТ (CtService.scorePaymentTerms); ответ прокси — уже
|
||||
* разобранный JSON, без обёртки.
|
||||
*/
|
||||
export async function askGemini<T>(context: WebPartContext, prompt: string, responseSchema: IGeminiSchema): Promise<T> {
|
||||
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||
const response: HttpClientResponse = await client.post(
|
||||
`${DICTIONARY_API_BASE_URL}/Proxy/Gemini`,
|
||||
AadHttpClient.configurations.v1,
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt, responseSchema })
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Proxy/Gemini: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const body = (await response.json()) as T & { error?: unknown };
|
||||
// Пустой ответ модели прокси отдаёт как 200 с { error } — это сбой, а не данные.
|
||||
if (body && typeof body.error === 'string') {
|
||||
throw new Error(`Proxy/Gemini: ${body.error}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ApprovalStatus,
|
||||
ICurrentListPartition,
|
||||
IApiResponse,
|
||||
IBasisRequest,
|
||||
IRequestType,
|
||||
IRequestTypeListSeries,
|
||||
IResolvedRoute,
|
||||
@@ -147,6 +148,32 @@ export async function getRequestTypeIdByPageUrl(context: WebPartContext, pageUrl
|
||||
return found.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/basis-requests — заявки типа requestTypeId, поданные по основанию
|
||||
* basisKey («ct:<Id>», «need:<oid>»), по всем партициям. Читаются на сервере от
|
||||
* имени приложения: права на элементы заявок разорваны поштучно, и прямым
|
||||
* чтением списка пользователь увидел бы только свои.
|
||||
*/
|
||||
export async function getBasisRequests(
|
||||
context: WebPartContext,
|
||||
requestTypeId: number,
|
||||
basisKey: string
|
||||
): Promise<IBasisRequest[]> {
|
||||
return withNetworkRetry(async () => {
|
||||
const c = await client(context);
|
||||
const query = `requestTypeId=${requestTypeId}&basisKey=${encodeURIComponent(basisKey)}`;
|
||||
const response: HttpClientResponse = await c.get(
|
||||
`${getApprovalApiBaseUrl()}/api/basis-requests?${query}`,
|
||||
AadHttpClient.configurations.v1
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`basis-requests: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const body: IApiResponse<IBasisRequest[]> = await response.json();
|
||||
return body.data ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/request-type-access/{requestTypeId}/emails — email'ы, кому в
|
||||
// админке approval-route выдан доступ видеть ВСЕ заявки этого типа (не только
|
||||
// свои). Тот же эндпоинт дёргает и Power Automate — здесь просто ещё один
|
||||
|
||||
@@ -19,6 +19,7 @@ export type {
|
||||
IListPartition,
|
||||
ICurrentListPartition,
|
||||
IRequestType,
|
||||
IBasisRequest,
|
||||
IRequestTypeListSeries,
|
||||
IResolvedRoute,
|
||||
IResolvedRouteStep,
|
||||
@@ -32,9 +33,10 @@ export {
|
||||
getListPartitions,
|
||||
getCurrentListPartition,
|
||||
getRequestTypeAccessEmails,
|
||||
getBasisRequests,
|
||||
resolveRoute
|
||||
} from './approvalRoute.service';
|
||||
export { hasRequestTypeAccess, accessibleAllRequestsTypes } from './requestTypeAccess';
|
||||
export { hasRequestTypeAccess, accessibleAllRequestsTypes, emailsForType } from './requestTypeAccess';
|
||||
export { useAccessibleAllRequestsFormKeys } from './useAccessibleAllRequestsFormKeys';
|
||||
export {
|
||||
initListPartitions,
|
||||
@@ -59,7 +61,7 @@ export { approvalServiceFor } from './approvalServiceFactory';
|
||||
export { ApproverService } from './approverDecision.service';
|
||||
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
|
||||
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
||||
export { notifyActiveStep } from './notify.service';
|
||||
export { notifyActiveStep, postToFlow } from './notify.service';
|
||||
export type { INotifyTarget } from './notify.service';
|
||||
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||
|
||||
@@ -126,3 +126,19 @@ export interface IResolvedRouteStep {
|
||||
*/
|
||||
positionCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Заявка, поданная по тому же основанию (СТ, потребность) — GET /api/basis-requests.
|
||||
* Сервер читает их от имени приложения, поэтому видны все, а не только свои;
|
||||
* canOpen — есть ли у текущего пользователя права открыть саму заявку.
|
||||
*/
|
||||
export interface IBasisRequest {
|
||||
id: number;
|
||||
/** Серверный относительный URL списка партиции, где лежит заявка. */
|
||||
listId: string;
|
||||
/** Номер заявки; пока не присвоен — пусто (сервер шлёт null). */
|
||||
uniqueCode?: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
canOpen: boolean;
|
||||
}
|
||||
|
||||
@@ -12,14 +12,15 @@ export interface INotifyTarget {
|
||||
approvalListName: string; // ApproveListName (список согласования)
|
||||
}
|
||||
|
||||
// POST в поток оповещения (Entra-триггер) через AadHttpClient — обычный fetch → 401,
|
||||
// AadHttpClient даёт токен Flow Service (и снимает вопрос CORS).
|
||||
async function postNotify(context: WebPartContext, body: Record<string, unknown>): Promise<void> {
|
||||
if (!FLOW_NOTIFY_URL) {
|
||||
// POST в поток Power Automate (Entra-триггер) через AadHttpClient — обычный fetch → 401,
|
||||
// AadHttpClient даёт токен Flow Service (и снимает вопрос CORS). Общий для всех
|
||||
// потоков оповещения портала (согласование, чат заявки — shared/chat/chatNotify.service.ts).
|
||||
export async function postToFlow(context: WebPartContext, flowUrl: string, body: object): Promise<void> {
|
||||
if (!flowUrl) {
|
||||
return;
|
||||
}
|
||||
const client = await context.aadHttpClientFactory.getClient(FLOW_RESOURCE);
|
||||
const res = await client.post(FLOW_NOTIFY_URL, AadHttpClient.configurations.v1, {
|
||||
const res = await client.post(flowUrl, AadHttpClient.configurations.v1, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
@@ -68,7 +69,7 @@ async function notifyRow(
|
||||
if (!(await isNotifyAllowed(approval, row.approverId))) {
|
||||
return;
|
||||
}
|
||||
await postNotify(context, {
|
||||
await postToFlow(context, FLOW_NOTIFY_URL, {
|
||||
RequestTypeId: target.requestTypeId,
|
||||
RowID: row.id,
|
||||
RequestID: requestId,
|
||||
|
||||
@@ -9,7 +9,12 @@ import { effectiveUserEmail } from '../impersonation';
|
||||
// кешируется, чтобы временный сбой сети не «залип» на весь сеанс.
|
||||
const cache = new Map<number, Promise<string[]>>();
|
||||
|
||||
function emailsForType(context: WebPartContext, requestTypeId: number): Promise<string[]> {
|
||||
/**
|
||||
* Кому выдан доступ видеть ВСЕ заявки этого типа (раздел «Все заявки»). Кешируется
|
||||
* на сеанс. Нужен не только для проверки доступа: эти люди могут писать в чат
|
||||
* любой заявки типа, значит, их можно и отмечать в нём (см. shared/chat).
|
||||
*/
|
||||
export function emailsForType(context: WebPartContext, requestTypeId: number): Promise<string[]> {
|
||||
let pending = cache.get(requestTypeId);
|
||||
if (!pending) {
|
||||
pending = getRequestTypeAccessEmails(context, requestTypeId).catch((e) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user