10 Commits

Author SHA1 Message Date
gorbenko.pavel 22e37b6e36 feat: прежняя заявка на оплату открывается в панели справа, а не в новой вкладке
Tests / frontend-test (pull_request) Successful in 1m27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:17:53 +05:00
gorbenko.pavel f654db9bcf docs: заявка на оплату — прежние оплаты через approval-route
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:01:46 +05:00
gorbenko.pavel 5cfe54f33b feat: прежние оплаты по основанию берутся из approval-route, ссылка — только при правах
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:01:34 +05:00
gorbenko.pavel 0c0104f6c9 feat: прежние заявки на оплату по основанию открываются по ссылке, видно статус
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:45:54 +05:00
gorbenko.pavel f664b468a2 fix: черновик заявки на оплату перечитывает прежние оплаты по основанию
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:42:15 +05:00
gorbenko.pavel 2bf0ad1da7 feat: разряды в полях сумм заявки на оплату
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:14:17 +05:00
gorbenko.pavel 6460a2a22d fix: счёт на оплату кладётся в библиотеку под латинским именем (OCR терял расширение)
Tests / frontend-test (pull_request) Successful in 1m24s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 14:49:25 +05:00
gorbenko.pavel edd5008120 fix: поля заявки на оплату занимают всю ширину секции
Тело CollapseSection из ui-kit уже сетка в две колонки, а поля были
завёрнуты ещё в один FormGrid и попадали в половину первой колонки.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 14:44:46 +05:00
gorbenko.pavel 5107f62e54 fix: таблицы заявки на оплату растягиваются на всю ширину секции
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 14:42:22 +05:00
gorbenko.pavel 780d92cd3d docs: заявка на оплату — списки, тип и маршрут заведены
Списки «Заявка на оплату 1 — данные / согласование» созданы провижинингом,
в approval-route заведены тип 24 (раздел «Бухгалтерия») и маршрут 25 с одним
шагом согласования на фиксированного человека.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:29:01 +05:00
18 changed files with 239 additions and 55 deletions
+1 -1
View File
@@ -52,7 +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С | ⏳ код готов, списки/библиотека/тип/маршрут не заведены |
| [`acc-payment-request`](../src/webparts/requestPortal/forms/PaymentRequest/README.md) | Заявка на оплату | Бухгалтерия | да | нет | да | основание — согласованная СТ (сайт СТ) или потребность; счёт PDF → библиотека → OCR → ИИ (реквизиты, суммы, позиции); сверка позиций счёта с СТ; подсветка чужого получателя и превышения общей суммы СТ; справочники 1С | ⏳ списки, библиотека, тип (id 24) и маршрут заведены; не проверена на живых данных |
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
**Просмотр** — есть режим согласующего (`<Форма>View`).
@@ -43,6 +43,7 @@ import { approvalListTitle, approvalListTitleForListId, dataListTitle, dataListT
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';
@@ -137,6 +138,16 @@ const PaymentRequestForm: React.FC<IRequestFormProps> = ({
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)}`);
@@ -3,11 +3,12 @@
**Назначение:** оплата по согласованной СТ (сравнительной таблице) или по потребности — счёт, получатель, суммы и сверка позиций счёта с СТ.
**Категория:** Бухгалтерия
**Списки SP:** «Заявка на оплату — данные», «Заявка на оплату — согласование»; библиотека «Счета на оплату» (создаётся вручную, колонка `file_content` — многострочный текст)
**RequestTypeId (внешний API маршрутов):** резолвится в рантайме по `pageUrl` (см. `docs/forms.md`) — тип `acc-payment-request` нужно завести в админке маршрутов
**RequestTypeId (внешний API маршрутов):** резолвится в рантайме по `pageUrl` (см. `docs/forms.md`); заведён тип 24, раздел «Бухгалтерия», список данных `/sites/requests/Lists/120`
## Маршрут
- Фазы: согласование (исполнителей нет)
- Маршрут зависит от проекта (`ProjectId`) — как у «Заявки на услуг»: пока проект не выбран, матрица не строится.
- Маршрут зависит от проекта (`ProjectId`) — как у «Заявки на услуги»: пока проект не выбран, матрица не строится.
- Маршрут 25 (`byProject: true`, без компании и подразделения — для любого проекта): один шаг согласования, фиксированный человек `gorbenko.pavel@tnsu.kz`. Форма всегда шлёт `projectId`, поэтому маршрута с `byProject: false` нет.
## Особое поведение
- **Основание** — согласованная СТ (`status = Согласовано` в списке «Сравнительная таблица» сайта СТ, `CT_SITE_URL`) либо потребность (`GET /Requests/365`, позиции — `/Requests/{oid}/positions`; тот же источник, что шаг 1 СТ). Выбор подставляет проект, компанию (если известна), получателя, раздел, вид работ, условия, общую сумму и позиции (`basisPatch.ts`); всё остаётся редактируемым.
@@ -16,7 +17,7 @@
- **Условия оплаты, доставки и срок** — из «Критериев» СТ по победителю.
- **Общая сумма** СТ — сумма предложений победителя в «Оценке поставщиков» (смешанные валюты — в тенге). У потребности её нет, берётся из счёта.
- **Сумма оплаты** может быть меньше общей (транши). Сумма всех оплат по основанию (прежние + эта) не может превышать общую — при превышении поле подсвечивается, отправку это **не** блокирует.
- **Ранее поданные заявки на оплату** — читаются из списка данных этой же формы по `BasisKey` (`ct:<Id СТ>` / `need:<oid>`), без черновиков, отклонённых и возвращённых. Только текущая партиция списка. Снимок пишется в `PreviousPaymentsJson`.
- **Ранее поданные заявки на оплату** — по `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,4 +1,4 @@
import { overLimitAmount, parseAmount, previousPaymentsTotal, recipientMatchesInvoice } from './amounts';
import { formatAmountInput, overLimitAmount, parseAmount, previousPaymentsTotal, recipientMatchesInvoice } from './amounts';
describe('parseAmount', () => {
it('понимает пробелы и запятую', () => {
@@ -45,3 +45,22 @@ describe('recipientMatchesInvoice', () => {
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);
});
});
@@ -9,6 +9,22 @@ export function parseAmount(raw: string): number {
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);
@@ -20,6 +20,12 @@ export interface IPreviousPayment {
/** Уникальный код заявки на оплату. */
code: string;
amount: number;
/** Id элемента списка данных — для ссылки на заявку. Нет, если у пользователя нет прав её открыть (или в старом снимке). */
id?: number;
/** Список партиции (серверный относительный URL), где лежит заявка; '' — текущая. */
listId?: string;
/** Статус заявки на момент чтения. */
status?: string;
}
/**
@@ -1,6 +1,6 @@
import * as React from 'react';
import { TextInput } from '../../../shared/ui';
import { parseAmount } from '../amounts';
import { formatAmountInput, parseAmount } from '../amounts';
interface IAmountInputProps {
id?: string;
@@ -8,10 +8,10 @@ interface IAmountInputProps {
onChange: (value: number) => void;
}
const toText = (value: number): string => (value ? String(value) : '');
const toText = (value: number): string => (value ? formatAmountInput(String(value)) : '');
/**
* Поле суммы с десятичной частью. Хранит значение числом, но пока пользователь
* Поле суммы с десятичной частью и разрядами («1 234 567,50»). Хранит значение числом, но пока пользователь
* печатает, держит собственный текст: иначе «12,» превращалось бы в «12» на
* каждом нажатии и дробную часть ввести было бы нельзя.
*/
@@ -28,8 +28,9 @@ export const AmountInput: React.FC<IAmountInputProps> = ({ id, value, onChange }
id={id}
value={text}
onChange={(next) => {
setText(next);
onChange(parseAmount(next));
const formatted = formatAmountInput(next);
setText(formatted);
onChange(parseAmount(formatted));
}}
/>
);
@@ -1,21 +1,24 @@
import * as React from 'react';
import styles from './PaymentSections.module.scss';
import { FormGrid, FormField, TextInput, TextArea } from '../../../shared/ui';
import { FormField, TextInput, TextArea } from '../../../shared/ui';
import { formatAmount, overLimitAmount, previousPaymentsTotal } from '../amounts';
import { ISectionProps } from '../model';
import { IPreviousPayment, ISectionProps } from '../model';
import { AmountInput } from './AmountInput';
import { RequestDrawer } from './RequestDrawer';
/**
* Суммы платежа. Сумма всех оплат по СТ (прежние заявки + эта) не может превышать
* общую сумму СТ — при превышении сумма оплаты подсвечивается, отправку это не блокирует.
*/
export const AmountsSection: React.FC<ISectionProps> = ({ data, set }) => {
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 (
<>
<FormGrid>
<FormField label="Общая сумма" htmlFor="TotalAmount">
<AmountInput id="TotalAmount" value={data.TotalAmount} onChange={(v) => set('TotalAmount', v)} />
</FormField>
@@ -42,37 +45,49 @@ export const AmountsSection: React.FC<ISectionProps> = ({ data, set }) => {
<FormField label="Комментарий" htmlFor="Comment" full>
<TextArea id="Comment" value={data.Comment} onChange={(v) => set('Comment', v)} rows={2} />
</FormField>
</FormGrid>
<h4>Заявки на оплату по этому основанию</h4>
<h4 className={styles.fullRow}>Заявки на оплату по этому основанию</h4>
{data.PreviousPayments.length === 0 ? (
<div className={styles.empty}>Ранее заявок на оплату по этому основанию не было.</div>
<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.code}</td>
<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>Итого ранее</td>
<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} />
)}
</>
);
};
@@ -1,5 +1,6 @@
import * as React from 'react';
import { Alert, FormGrid, FormField, Select, ISelectOption } from '../../../shared/ui';
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';
@@ -43,7 +44,6 @@ export const BasisSection: React.FC<IBasisSectionProps> = ({ data, patch, contex
return (
<>
<FormGrid>
<FormField label="Основание" htmlFor="BasisKind" required>
<Select id="BasisKind" value={data.BasisKind} onChange={handleKindChange} options={KIND_OPTIONS} />
</FormField>
@@ -80,9 +80,8 @@ export const BasisSection: React.FC<IBasisSectionProps> = ({ data, patch, contex
placeholder="Выберите компанию…"
/>
</FormField>
</FormGrid>
{basis.loading && <Alert variant="info">Загружаем данные основания…</Alert>}
{basis.error && <Alert variant="err">{basis.error}</Alert>}
{basis.loading && <div className={styles.fullRow}><Alert variant="info">Загружаем данные основания…</Alert></div>}
{basis.error && <div className={styles.fullRow}><Alert variant="err">{basis.error}</Alert></div>}
</>
);
};
@@ -1,6 +1,6 @@
import * as React from 'react';
import styles from './PaymentSections.module.scss';
import { FormGrid, FormField, TextInput, TextArea } from '../../../shared/ui';
import { FormField, TextInput, TextArea } from '../../../shared/ui';
import {
DictionaryPicker,
getCashFlowGroups,
@@ -25,7 +25,7 @@ export const PaymentSection: React.FC<ISectionProps> = ({ data, set, patch, cont
const accountFilter = { companyId: data.CompanyId, projectId: data.ProjectId };
return (
<FormGrid>
<>
<FormField label="Банковский счёт" htmlFor="BankAccountName" required>
<DictionaryPicker
id="BankAccountName"
@@ -99,6 +99,6 @@ export const PaymentSection: React.FC<ISectionProps> = ({ data, set, patch, cont
<FormField label="Условия оплаты" htmlFor="PaymentTerms">
<TextArea id="PaymentTerms" value={data.PaymentTerms} onChange={(v) => set('PaymentTerms', v)} rows={2} />
</FormField>
</FormGrid>
</>
);
};
@@ -38,6 +38,11 @@
}
.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;
@@ -97,3 +102,24 @@
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,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
);
@@ -8,7 +8,7 @@ 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, storedFileName } from '../../../shared/chat/chatFileNames';
import { fileStamp } from '../../../shared/chat/chatFileNames';
/** Колонка библиотеки, куда OCR кладёт распознанный текст счёта. */
const FILE_CONTENT_FIELD = 'file_content';
@@ -37,10 +37,13 @@ export function validateInvoiceFile(file: File): string | undefined {
/**
* Сохранить файл счёта в библиотеку. Делаем это сразу при выборе файла, до
* сохранения самой заявки: OCR работает с элементом библиотеки, а не с вложением.
* Отметка времени в имени исключает перезапись счёта с тем же именем из другой заявки.
* Имя в библиотеке — только латиница и цифры: OCR-сервис получает имя в multipart и
* на кириллице теряет расширение («unsupported file type ''»). Исходное имя
* хранится в данных заявки (InvoiceFileName), поэтому в библиотеке оно не нужно.
* Метка времени и случайный хвост исключают перезапись счёта другой заявки.
*/
export async function storeInvoice(file: File): Promise<IStoredInvoice> {
const storedName = storedFileName(fileStamp(new Date()), 1, file.name);
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 отдаёт только сведения о файле.
@@ -1,6 +1,5 @@
import { getSP } from '../../../shared/pnp/pnpjs';
import { dataListTitle } from '../../../shared/listTitles';
import { REQUEST_STATUS } from '../../../shared/approval';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { REQUEST_STATUS, getBasisRequests, getRequestTypeIdByPageUrl } from '../../../shared/approval';
import { FORM_ID, IPreviousPayment } from '../model';
/**
@@ -9,32 +8,34 @@ import { FORM_ID, IPreviousPayment } from '../model';
*/
const NOT_COUNTED_STATUSES: string[] = [REQUEST_STATUS.draft, REQUEST_STATUS.rejected, REQUEST_STATUS.returned];
const MAX_PAYMENTS = 500;
interface IPaymentRow {
Id: number;
RequestUniqueCode?: string;
PayAmount?: number;
RequestStatus?: string;
}
/**
* Прежние заявки на оплату по тому же основанию (СТ или потребности). Читаем
* текущую партицию списка: архивные пары списков сюда не заглядывают — оплаты
* по одной СТ укладываются во время жизни одной партиции.
* Прежние заявки на оплату по тому же основанию (СТ или потребности) — по всем
* партициям списка.
*
* Читаем не список напрямую, а через approval-route (GET /api/basis-requests):
* права на элементы заявок разорваны поштучно, и под своей учёткой заявитель
* видел бы только свои оплаты — сумма по СТ занижалась бы и проверка превышения
* пропускала переплату. Сервер отдаёт все заявки, а canOpen говорит, можно ли
* текущему пользователю открыть каждую (без прав ссылки в таблице нет).
*
* excludeId — сама редактируемая заявка: себя в «ранее поданных» показывать нельзя.
*/
export async function loadPreviousPayments(basisKey: string, excludeId?: number): Promise<IPreviousPayment[]> {
export async function loadPreviousPayments(
context: WebPartContext,
basisKey: string,
excludeId?: number
): Promise<IPreviousPayment[]> {
if (!basisKey) return [];
const safeKey = basisKey.replace(/'/g, "''");
const rows = (await getSP()
.web.lists.getByTitle(dataListTitle(FORM_ID))
.items.select('Id', 'RequestUniqueCode', 'PayAmount', 'RequestStatus')
.filter(`BasisKey eq '${safeKey}'`)
.top(MAX_PAYMENTS)()) as IPaymentRow[];
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.RequestStatus || '') === -1)
.map((row) => ({ code: row.RequestUniqueCode || `№${row.Id}`, amount: Number(row.PayAmount || 0) }));
.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
}));
}
@@ -35,7 +35,7 @@ export function useBasisSelection(
try {
const info = kind === 'ct' ? await loadCtBasis(context, id) : await loadNeedBasis(context, id);
const [previous, projects, companies] = await Promise.all([
loadPreviousPayments(info.key, editId),
loadPreviousPayments(context, info.key, editId),
getProjects(context).catch(() => []),
getCompanies(context).catch(() => [])
]);
@@ -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,6 +33,7 @@ export {
getListPartitions,
getCurrentListPartition,
getRequestTypeAccessEmails,
getBasisRequests,
resolveRoute
} from './approvalRoute.service';
export { hasRequestTypeAccess, accessibleAllRequestsTypes, emailsForType } from './requestTypeAccess';
@@ -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;
}