From e976b4e8d4fe908e4f27473ff318a2b3e11505e1 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 22 Oct 2026 15:01:28 +0500 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=20?= =?UTF-8?q?=C2=AB=D0=97=D0=B0=D1=8F=D0=B2=D0=BA=D0=B0=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BE=D0=BF=D0=BB=D0=B0=D1=82=D1=83=C2=BB=20(=D0=A1=D0=A2/?= =?UTF-8?q?=D0=BF=D0=BE=D1=82=D1=80=D0=B5=D0=B1=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C,=20=D1=81=D1=87=D1=91=D1=82=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20OCR=20=D0=B8=20=D0=98=D0=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая форма acc-payment-request в категории «Бухгалтерия»: основание — согласованная СТ (сайт СТ) или потребность; из основания подставляются получатель, раздел, вид работ, условия, общая сумма и позиции. Счёт (PDF) сохраняется в библиотеку, уходит на OCR, ИИ достаёт реквизиты, суммы и позиции и сверяет их с СТ. Подсвечиваются чужой получатель в счёте и превышение общей суммы СТ всеми оплатами. Справочники 1С — общий DictionaryPicker. Co-Authored-By: Claude Sonnet 5.5 --- docs/forms.md | 1 + src/webparts/requestPortal/data/requests.ts | 6 + .../PaymentRequestForm.module.scss | 20 ++ .../PaymentRequest/PaymentRequestForm.tsx | 328 ++++++++++++++++++ .../PaymentRequestView.module.scss | 17 + .../PaymentRequest/PaymentRequestView.tsx | 197 +++++++++++ .../forms/PaymentRequest/README.md | 36 ++ .../forms/PaymentRequest/amounts.test.ts | 47 +++ .../forms/PaymentRequest/amounts.ts | 59 ++++ .../PaymentRequest/approval/provisioning.json | 17 + .../forms/PaymentRequest/basisPatch.ts | 54 +++ .../forms/PaymentRequest/invoicePatch.ts | 62 ++++ .../forms/PaymentRequest/load.ts | 110 ++++++ .../forms/PaymentRequest/model.ts | 183 ++++++++++ .../forms/PaymentRequest/positions.test.ts | 63 ++++ .../forms/PaymentRequest/positions.ts | 129 +++++++ .../forms/PaymentRequest/provisioning.json | 57 +++ .../PaymentRequest/sections/AmountInput.tsx | 36 ++ .../sections/AmountsSection.tsx | 78 +++++ .../PaymentRequest/sections/BasisPicker.tsx | 65 ++++ .../PaymentRequest/sections/BasisSection.tsx | 88 +++++ .../PaymentRequest/sections/HeaderSection.tsx | 37 ++ .../sections/InvoiceSection.tsx | 70 ++++ .../sections/PaymentSection.tsx | 98 ++++++ .../sections/PaymentSections.module.scss | 99 ++++++ .../sections/PositionsSection.tsx | 85 +++++ .../PaymentRequest/services/basis.types.ts | 47 +++ .../services/ctBasis.service.ts | 210 +++++++++++ .../services/invoice.service.ts | 97 ++++++ .../services/invoiceAi.service.ts | 115 ++++++ .../services/needBasis.service.ts | 104 ++++++ .../services/previousPayments.service.ts | 40 +++ .../forms/PaymentRequest/submit.ts | 55 +++ .../forms/PaymentRequest/useBasisSelection.ts | 53 +++ .../forms/PaymentRequest/useInvoiceFlow.ts | 68 ++++ .../forms/PaymentRequest/validation.ts | 23 ++ src/webparts/requestPortal/forms/registry.ts | 3 + .../requestPortal/forms/viewRegistry.ts | 3 + .../requestPortal/shared/ai/gemini.service.ts | 32 ++ src/webparts/requestPortal/shared/config.ts | 7 + .../dictionary/dictionaryList.service.ts | 83 +++++ .../requestPortal/shared/dictionary/index.ts | 6 + .../shared/dictionary/ui/DictionaryPicker.tsx | 57 +++ .../shared/dictionary/ui/ItemPicker.tsx | 92 +++++ .../requestPortal/shared/listTitles.ts | 10 +- 45 files changed, 3146 insertions(+), 1 deletion(-) create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/PaymentRequestForm.module.scss create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/PaymentRequestForm.tsx create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/PaymentRequestView.module.scss create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/PaymentRequestView.tsx create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/README.md create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/amounts.test.ts create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/amounts.ts create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/approval/provisioning.json create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/basisPatch.ts create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/invoicePatch.ts create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/load.ts create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/model.ts create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/positions.test.ts create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/positions.ts create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/provisioning.json create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/sections/AmountInput.tsx create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/sections/AmountsSection.tsx create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/sections/BasisPicker.tsx create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/sections/BasisSection.tsx create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/sections/HeaderSection.tsx create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/sections/InvoiceSection.tsx create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/sections/PaymentSection.tsx create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/sections/PaymentSections.module.scss create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/sections/PositionsSection.tsx create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/services/basis.types.ts create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/services/ctBasis.service.ts create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/services/invoice.service.ts create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/services/invoiceAi.service.ts create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/services/needBasis.service.ts create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/services/previousPayments.service.ts create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/submit.ts create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/useBasisSelection.ts create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/useInvoiceFlow.ts create mode 100644 src/webparts/requestPortal/forms/PaymentRequest/validation.ts create mode 100644 src/webparts/requestPortal/shared/ai/gemini.service.ts create mode 100644 src/webparts/requestPortal/shared/dictionary/dictionaryList.service.ts create mode 100644 src/webparts/requestPortal/shared/dictionary/ui/DictionaryPicker.tsx create mode 100644 src/webparts/requestPortal/shared/dictionary/ui/ItemPicker.tsx diff --git a/docs/forms.md b/docs/forms.md index ef4e706..8a33c6f 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -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С | ⏳ код готов, списки/библиотека/тип/маршрут не заведены | Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения; **Просмотр** — есть режим согласующего (`<Форма>View`). diff --git a/src/webparts/requestPortal/data/requests.ts b/src/webparts/requestPortal/data/requests.ts index f444c8d..a43f6aa 100644 --- a/src/webparts/requestPortal/data/requests.ts +++ b/src/webparts/requestPortal/data/requests.ts @@ -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', diff --git a/src/webparts/requestPortal/forms/PaymentRequest/PaymentRequestForm.module.scss b/src/webparts/requestPortal/forms/PaymentRequest/PaymentRequestForm.module.scss new file mode 100644 index 0000000..e1ef36d --- /dev/null +++ b/src/webparts/requestPortal/forms/PaymentRequest/PaymentRequestForm.module.scss @@ -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; + } +} diff --git a/src/webparts/requestPortal/forms/PaymentRequest/PaymentRequestForm.tsx b/src/webparts/requestPortal/forms/PaymentRequest/PaymentRequestForm.tsx new file mode 100644 index 0000000..c6514d5 --- /dev/null +++ b/src/webparts/requestPortal/forms/PaymentRequest/PaymentRequestForm.tsx @@ -0,0 +1,328 @@ +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 { 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 = ({ + requestType, + context, + onCancel, + onSaved, + editId, + editListId, + onCodeLoaded +}) => { + const [data, setData] = useState(EMPTY_DATA); + const [requestId, setRequestId] = useState(editId); + const [requestStatus, setRequestStatus] = useState(undefined); + const [requestCode, setRequestCode] = useState(undefined); + const submit = useRequestSubmit(); + + const readOnly = !isEditableStatus(requestStatus); + + const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); + const patch = useCallback((changes: Partial): 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); + }) + .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) => async (): Promise => { + 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 => submit.run('draft', save(REQUEST_STATUS.draft), validate); + const handleSubmit = (): Promise => + 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 ( +
+ + + + } + headerRight={ + requestId || requestStatus ? ( + <> + {requestStatus && } + {requestCode && } + + ) : undefined + } + > + {submit.savedKind && !submit.error && ( + {submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'} + )} + {submit.error && {submit.error}} + + + + + + + + + + + + + + + + + + + + + approval.searchUsers(q)} + waiting={!matrixReady} + waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования." + /> + approval.searchUsers(q)} /> + + {editId && ( +
+ +
+ )} + + +
+
+ ); +}; + +export default PaymentRequestForm; diff --git a/src/webparts/requestPortal/forms/PaymentRequest/PaymentRequestView.module.scss b/src/webparts/requestPortal/forms/PaymentRequest/PaymentRequestView.module.scss new file mode 100644 index 0000000..b52e802 --- /dev/null +++ b/src/webparts/requestPortal/forms/PaymentRequest/PaymentRequestView.module.scss @@ -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; +} diff --git a/src/webparts/requestPortal/forms/PaymentRequest/PaymentRequestView.tsx b/src/webparts/requestPortal/forms/PaymentRequest/PaymentRequestView.tsx new file mode 100644 index 0000000..af8ed67 --- /dev/null +++ b/src/webparts/requestPortal/forms/PaymentRequest/PaymentRequestView.tsx @@ -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 = ({ context, itemId, formKey, listId, onCodeLoaded }) => { + const notifyCode = useStableCallback(onCodeLoaded); + const [data, setData] = useState(EMPTY_DATA); + const [status, setStatus] = useState(''); + const [code, setCode] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [printOrgIndex, setPrintOrgIndex] = useState(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(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
Загрузка заявки…
; + } + if (error) { + return ( +
+ {error} +
+ ); + } + + 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 ( +
+
+ {isEditableStatus(status) && ( + + )} + +
+ +
+ + } + headerRight={status ? : undefined} + > + + + +
+ {data.BasisTitle} · {data.ProjectName} · {data.CompanyName} +
+
+ + + + + + + + + + + + +
+
+ + approval.searchUsers(q)} /> + approval.searchUsers(q)} /> +
+ +
+ +
+ +
+ approval.searchUsers(q)} + onDecided={(newStatus) => { + setStatus(newStatus); + setDecisionSeq((n) => n + 1); + notify(); + }} + onDelegated={() => { + setDecisionSeq((n) => n + 1); + notify(); + }} + /> +
+
+ ); +}; + +export default PaymentRequestView; diff --git a/src/webparts/requestPortal/forms/PaymentRequest/README.md b/src/webparts/requestPortal/forms/PaymentRequest/README.md new file mode 100644 index 0000000..ba2d981 --- /dev/null +++ b/src/webparts/requestPortal/forms/PaymentRequest/README.md @@ -0,0 +1,36 @@ +# Заявка на оплату (`acc-payment-request`) + +**Назначение:** оплата по согласованной СТ (сравнительной таблице) или по потребности — счёт, получатель, суммы и сверка позиций счёта с СТ. +**Категория:** Бухгалтерия +**Списки SP:** «Заявка на оплату — данные», «Заявка на оплату — согласование»; библиотека «Счета на оплату» (создаётся вручную, колонка `file_content` — многострочный текст) +**RequestTypeId (внешний API маршрутов):** резолвится в рантайме по `pageUrl` (см. `docs/forms.md`) — тип `acc-payment-request` нужно завести в админке маршрутов + +## Маршрут +- Фазы: согласование (исполнителей нет) +- Маршрут зависит от проекта (`ProjectId`) — как у «Заявки на услуг»: пока проект не выбран, матрица не строится. + +## Особое поведение +- **Основание** — согласованная СТ (`status = Согласовано` в списке «Сравнительная таблица» сайта СТ, `CT_SITE_URL`) либо потребность (`GET /Requests/365`, позиции — `/Requests/{oid}/positions`; тот же источник, что шаг 1 СТ). Выбор подставляет проект, компанию (если известна), получателя, раздел, вид работ, условия, общую сумму и позиции (`basisPatch.ts`); всё остаётся редактируемым. +- **Получатель** — выигравший поставщик СТ (`winSupplier` → «Поставщики»). Если в счёте указан другой контрагент (БИН, иначе название без формы собственности) — поле подсвечивается (`recipientMatchesInvoice`). +- **Раздел / вид работ** у СТ — из позиций СТ (`disciplineCode` / `SMR`), у потребности — `discipline` / `workType` заявки. +- **Условия оплаты, доставки и срок** — из «Критериев» СТ по победителю. +- **Общая сумма** СТ — сумма предложений победителя в «Оценке поставщиков» (смешанные валюты — в тенге). У потребности её нет, берётся из счёта. +- **Сумма оплаты** может быть меньше общей (транши). Сумма всех оплат по основанию (прежние + эта) не может превышать общую — при превышении поле подсвечивается, отправку это **не** блокирует. +- **Ранее поданные заявки на оплату** — читаются из списка данных этой же формы по `BasisKey` (`ct:` / `need:`), без черновиков, отклонённых и возвращённых. Только текущая партиция списка. Снимок пишется в `PreviousPaymentsJson`. +- **Счёт (PDF)**: сначала сохраняется в библиотеку «Счета на оплату», затем `POST /Document/extractPDF` (OCR кладёт текст в `file_content`), затем ИИ (`/Proxy/Gemini`) достаёт номер счёта, поставщика, БИН, IBAN, валюту, суммы и позиции и подставляет их (назначение платежа — «Оплата по счёту № …»). При сбое OCR/ИИ счёт остаётся приложенным, поля заполняются вручную. +- **Таблица позиций**: код, наименование, ед. изм. и количество — из СТ/потребности; позиция поставщика — из его КП; позиция из счёта. ИИ сопоставляет позиции по смыслу, а количество и единицы сверяет код (`positions.ts`). Статусы: совпало / расхождение / нет в счёте / нет в СТ. Результат пишется в `PositionsJson`. +- Справочники 1С (банковские счета, договоры, группы и статьи ДДС) — `DictionaryPicker`; недоступный справочник не блокирует заявку (поле становится текстовым). + +## Что не проверено на живом API +- Форма ответов и параметры фильтра `Dictionary/BankAccounts` (`companyId`, `projectId`), `Dictionary/Contracts` (`companyId`), `Dictionary/CashFlowItemGroups`, `Dictionary/CashFlowItems` (`groupId` — адрес и параметр статьи ДДС в ТЗ не указаны, взяты по аналогии). Поля записи читаются по списку вероятных имён (`dictionaryList.service.ts`). +- Тело запроса `Document/extractPDF` — взято по аналогии с `extractPDFText` из СТ (`siteUrl`, `libraryName`, `itemId`). +- «Потребности» взяты из `/Requests/{дни}`, а не из `/Dictionary/Positions`: в портале это справочник должностей. +- Доступ пользователей портала на чтение списков сайта СТ. + +## Секции формы +HeaderSection, BasisSection (BasisPicker), InvoiceSection, PaymentSection, AmountsSection, PositionsSection + +## Точки входа +- rptype: `acc-payment-request` +- Режим согласующего (просмотр): есть (`PaymentRequestView`) +- «Создать на основе»: — diff --git a/src/webparts/requestPortal/forms/PaymentRequest/amounts.test.ts b/src/webparts/requestPortal/forms/PaymentRequest/amounts.test.ts new file mode 100644 index 0000000..eb41ee4 --- /dev/null +++ b/src/webparts/requestPortal/forms/PaymentRequest/amounts.test.ts @@ -0,0 +1,47 @@ +import { 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); + }); +}); diff --git a/src/webparts/requestPortal/forms/PaymentRequest/amounts.ts b/src/webparts/requestPortal/forms/PaymentRequest/amounts.ts new file mode 100644 index 0000000..fb48de2 --- /dev/null +++ b/src/webparts/requestPortal/forms/PaymentRequest/amounts.ts @@ -0,0 +1,59 @@ +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; +} + +/** Сумма ранее поданных оплат по основанию. */ +export function previousPaymentsTotal(payments: IPreviousPayment[]): number { + return payments.reduce((sum, p) => sum + p.amount, 0); +} + +/** + * На сколько все оплаты по основанию (прежние + эта) превышают общую сумму СТ. + * 0 — не превышают; общая сумма не задана — сравнивать не с чем, тоже 0. + */ +export function overLimitAmount(data: Pick): 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; +} diff --git a/src/webparts/requestPortal/forms/PaymentRequest/approval/provisioning.json b/src/webparts/requestPortal/forms/PaymentRequest/approval/provisioning.json new file mode 100644 index 0000000..a0847f6 --- /dev/null +++ b/src/webparts/requestPortal/forms/PaymentRequest/approval/provisioning.json @@ -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" } + ] +} diff --git a/src/webparts/requestPortal/forms/PaymentRequest/basisPatch.ts b/src/webparts/requestPortal/forms/PaymentRequest/basisPatch.ts new file mode 100644 index 0000000..b3bb2d6 --- /dev/null +++ b/src/webparts/requestPortal/forms/PaymentRequest/basisPatch.ts @@ -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 { + 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) + }; +} diff --git a/src/webparts/requestPortal/forms/PaymentRequest/invoicePatch.ts b/src/webparts/requestPortal/forms/PaymentRequest/invoicePatch.ts new file mode 100644 index 0000000..28ddc8e --- /dev/null +++ b/src/webparts/requestPortal/forms/PaymentRequest/invoicePatch.ts @@ -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 { + const patch: Partial = { + 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; +} diff --git a/src/webparts/requestPortal/forms/PaymentRequest/load.ts b/src/webparts/requestPortal/forms/PaymentRequest/load.ts new file mode 100644 index 0000000..eb64313 --- /dev/null +++ b/src/webparts/requestPortal/forms/PaymentRequest/load.ts @@ -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(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 { + 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; + + const data: IFormData = { + ...EMPTY_DATA, + RequestDate: spDateOnlyToIso(item.RequestDate), + BasisKind: item.BasisKind === 'need' ? ('need' as BasisKind) : 'ct', + PreviousPayments: parseRows(item.PreviousPaymentsJson), + Positions: parseRows(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) || '' + }; +} diff --git a/src/webparts/requestPortal/forms/PaymentRequest/model.ts b/src/webparts/requestPortal/forms/PaymentRequest/model.ts new file mode 100644 index 0000000..0de3cd1 --- /dev/null +++ b/src/webparts/requestPortal/forms/PaymentRequest/model.ts @@ -0,0 +1,183 @@ +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; +} + +/** + * Результат сверки строки с позициями счёта: + * 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:» либо «need:». */ + 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 = (key: K, value: IFormData[K]) => void; + +/** Пропсы каждой секции формы. */ +export interface ISectionProps { + data: IFormData; + set: SetField; + context: WebPartContext; + /** Подставить сразу несколько полей (выбор основания меняет их пачкой). */ + patch: (changes: Partial) => 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: [] +}; diff --git a/src/webparts/requestPortal/forms/PaymentRequest/positions.test.ts b/src/webparts/requestPortal/forms/PaymentRequest/positions.test.ts new file mode 100644 index 0000000..d7de80a --- /dev/null +++ b/src/webparts/requestPortal/forms/PaymentRequest/positions.test.ts @@ -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); + }); +}); diff --git a/src/webparts/requestPortal/forms/PaymentRequest/positions.ts b/src/webparts/requestPortal/forms/PaymentRequest/positions.ts new file mode 100644 index 0000000..ffa80c1 --- /dev/null +++ b/src/webparts/requestPortal/forms/PaymentRequest/positions.ts @@ -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 { + return { invoiceName: '', invoiceUnit: '', invoiceQty: 0, invoicePrice: 0, invoiceSum: 0 }; +} + +function invoicePart(item: IInvoiceItem): ReturnType { + 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 { + 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(); + const usedInvoice = new Set(); + + 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'); +} diff --git a/src/webparts/requestPortal/forms/PaymentRequest/provisioning.json b/src/webparts/requestPortal/forms/PaymentRequest/provisioning.json new file mode 100644 index 0000000..3679f7c --- /dev/null +++ b/src/webparts/requestPortal/forms/PaymentRequest/provisioning.json @@ -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" } + ] +} diff --git a/src/webparts/requestPortal/forms/PaymentRequest/sections/AmountInput.tsx b/src/webparts/requestPortal/forms/PaymentRequest/sections/AmountInput.tsx new file mode 100644 index 0000000..6e66e4c --- /dev/null +++ b/src/webparts/requestPortal/forms/PaymentRequest/sections/AmountInput.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { TextInput } from '../../../shared/ui'; +import { parseAmount } from '../amounts'; + +interface IAmountInputProps { + id?: string; + value: number; + onChange: (value: number) => void; +} + +const toText = (value: number): string => (value ? String(value) : ''); + +/** + * Поле суммы с десятичной частью. Хранит значение числом, но пока пользователь + * печатает, держит собственный текст: иначе «12,» превращалось бы в «12» на + * каждом нажатии и дробную часть ввести было бы нельзя. + */ +export const AmountInput: React.FC = ({ id, value, onChange }) => { + const [text, setText] = React.useState(toText(value)); + + // Значение изменили снаружи (подставил счёт, загрузилась заявка) — показываем его. + React.useEffect(() => { + setText((current) => (parseAmount(current) === value ? current : toText(value))); + }, [value]); + + return ( + { + setText(next); + onChange(parseAmount(next)); + }} + /> + ); +}; diff --git a/src/webparts/requestPortal/forms/PaymentRequest/sections/AmountsSection.tsx b/src/webparts/requestPortal/forms/PaymentRequest/sections/AmountsSection.tsx new file mode 100644 index 0000000..24a3527 --- /dev/null +++ b/src/webparts/requestPortal/forms/PaymentRequest/sections/AmountsSection.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import styles from './PaymentSections.module.scss'; +import { FormGrid, FormField, TextInput, TextArea } from '../../../shared/ui'; +import { formatAmount, overLimitAmount, previousPaymentsTotal } from '../amounts'; +import { ISectionProps } from '../model'; +import { AmountInput } from './AmountInput'; + +/** + * Суммы платежа. Сумма всех оплат по СТ (прежние заявки + эта) не может превышать + * общую сумму СТ — при превышении сумма оплаты подсвечивается, отправку это не блокирует. + */ +export const AmountsSection: React.FC = ({ data, set }) => { + const over = overLimitAmount(data); + const previousTotal = previousPaymentsTotal(data.PreviousPayments); + + return ( + <> + + + set('TotalAmount', v)} /> + + + set('Currency', v.toUpperCase())} placeholder="KZT" /> + + + set('VatAmount', v)} /> + + +
0 ? styles.warnBox : undefined}> + set('PayAmount', v)} /> + {over > 0 && ( +
+ Все оплаты по основанию превышают общую сумму на {formatAmount(over)} {data.Currency}. +
+ )} +
+
+ + + set('DeliveryTime', v)} /> + + +