Compare commits
6 Commits
6460a2a22d
...
22e37b6e36
| Author | SHA1 | Date | |
|---|---|---|---|
| 22e37b6e36 | |||
| f654db9bcf | |||
| 5cfe54f33b | |||
| 0c0104f6c9 | |||
| f664b468a2 | |||
| 2bf0ad1da7 |
@@ -43,6 +43,7 @@ import { approvalListTitle, approvalListTitleForListId, dataListTitle, dataListT
|
|||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { savePaymentRequest, paymentRequestApprovalService } from './submit';
|
import { savePaymentRequest, paymentRequestApprovalService } from './submit';
|
||||||
import { loadPaymentRequest } from './load';
|
import { loadPaymentRequest } from './load';
|
||||||
|
import { loadPreviousPayments } from './services/previousPayments.service';
|
||||||
import { validatePayment } from './validation';
|
import { validatePayment } from './validation';
|
||||||
import { useBasisSelection } from './useBasisSelection';
|
import { useBasisSelection } from './useBasisSelection';
|
||||||
import { useInvoiceFlow } from './useInvoiceFlow';
|
import { useInvoiceFlow } from './useInvoiceFlow';
|
||||||
@@ -137,6 +138,16 @@ const PaymentRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
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) => {
|
.catch((e) => {
|
||||||
if (alive) submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
if (alive) submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
|
|||||||
@@ -17,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/ИИ счёт остаётся приложенным, поля заполняются вручную.
|
- **Счёт (PDF)**: сначала сохраняется в библиотеку «Счета на оплату», затем `POST /Document/extractPDFText` (OCR записывает текст в `file_content` до ответа, колонка читается сразу), затем ИИ (`/Proxy/Gemini`) достаёт номер счёта, поставщика, БИН, IBAN, валюту, суммы и позиции и подставляет их (назначение платежа — «Оплата по счёту № …»). При сбое OCR/ИИ счёт остаётся приложенным, поля заполняются вручную.
|
||||||
- **Таблица позиций**: код, наименование, ед. изм. и количество — из СТ/потребности; позиция поставщика — из его КП; позиция из счёта. ИИ сопоставляет позиции по смыслу, а количество и единицы сверяет код (`positions.ts`). Статусы: совпало / расхождение / нет в счёте / нет в СТ. Результат пишется в `PositionsJson`.
|
- **Таблица позиций**: код, наименование, ед. изм. и количество — из СТ/потребности; позиция поставщика — из его КП; позиция из счёта. ИИ сопоставляет позиции по смыслу, а количество и единицы сверяет код (`positions.ts`). Статусы: совпало / расхождение / нет в счёте / нет в СТ. Результат пишется в `PositionsJson`.
|
||||||
- **Код позиции потребности** — `lineNumber` (номер строки табличной части 1С, уникален в пределах заявки).
|
- **Код позиции потребности** — `lineNumber` (номер строки табличной части 1С, уникален в пределах заявки).
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { overLimitAmount, parseAmount, previousPaymentsTotal, recipientMatchesInvoice } from './amounts';
|
import { formatAmountInput, overLimitAmount, parseAmount, previousPaymentsTotal, recipientMatchesInvoice } from './amounts';
|
||||||
|
|
||||||
describe('parseAmount', () => {
|
describe('parseAmount', () => {
|
||||||
it('понимает пробелы и запятую', () => {
|
it('понимает пробелы и запятую', () => {
|
||||||
@@ -45,3 +45,22 @@ describe('recipientMatchesInvoice', () => {
|
|||||||
expect(recipientMatchesInvoice({ name: 'ТОО «Альфа»', bin: '1' }, { name: '', bin: '' })).toBe(true);
|
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;
|
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 {
|
export function previousPaymentsTotal(payments: IPreviousPayment[]): number {
|
||||||
return payments.reduce((sum, p) => sum + p.amount, 0);
|
return payments.reduce((sum, p) => sum + p.amount, 0);
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export interface IPreviousPayment {
|
|||||||
/** Уникальный код заявки на оплату. */
|
/** Уникальный код заявки на оплату. */
|
||||||
code: string;
|
code: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
/** Id элемента списка данных — для ссылки на заявку. Нет, если у пользователя нет прав её открыть (или в старом снимке). */
|
||||||
|
id?: number;
|
||||||
|
/** Список партиции (серверный относительный URL), где лежит заявка; '' — текущая. */
|
||||||
|
listId?: string;
|
||||||
|
/** Статус заявки на момент чтения. */
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { TextInput } from '../../../shared/ui';
|
import { TextInput } from '../../../shared/ui';
|
||||||
import { parseAmount } from '../amounts';
|
import { formatAmountInput, parseAmount } from '../amounts';
|
||||||
|
|
||||||
interface IAmountInputProps {
|
interface IAmountInputProps {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -8,10 +8,10 @@ interface IAmountInputProps {
|
|||||||
onChange: (value: number) => void;
|
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» на
|
* печатает, держит собственный текст: иначе «12,» превращалось бы в «12» на
|
||||||
* каждом нажатии и дробную часть ввести было бы нельзя.
|
* каждом нажатии и дробную часть ввести было бы нельзя.
|
||||||
*/
|
*/
|
||||||
@@ -28,8 +28,9 @@ export const AmountInput: React.FC<IAmountInputProps> = ({ id, value, onChange }
|
|||||||
id={id}
|
id={id}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
setText(next);
|
const formatted = formatAmountInput(next);
|
||||||
onChange(parseAmount(next));
|
setText(formatted);
|
||||||
|
onChange(parseAmount(formatted));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ import * as React from 'react';
|
|||||||
import styles from './PaymentSections.module.scss';
|
import styles from './PaymentSections.module.scss';
|
||||||
import { FormField, TextInput, TextArea } from '../../../shared/ui';
|
import { FormField, TextInput, TextArea } from '../../../shared/ui';
|
||||||
import { formatAmount, overLimitAmount, previousPaymentsTotal } from '../amounts';
|
import { formatAmount, overLimitAmount, previousPaymentsTotal } from '../amounts';
|
||||||
import { ISectionProps } from '../model';
|
import { IPreviousPayment, ISectionProps } from '../model';
|
||||||
import { AmountInput } from './AmountInput';
|
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 over = overLimitAmount(data);
|
||||||
const previousTotal = previousPaymentsTotal(data.PreviousPayments);
|
const previousTotal = previousPaymentsTotal(data.PreviousPayments);
|
||||||
|
|
||||||
@@ -51,26 +55,39 @@ export const AmountsSection: React.FC<ISectionProps> = ({ data, set }) => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Номер заявки на оплату</th>
|
<th>Номер заявки на оплату</th>
|
||||||
|
<th>Статус</th>
|
||||||
<th className={styles.num}>Сумма оплаты</th>
|
<th className={styles.num}>Сумма оплаты</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.PreviousPayments.map((p) => (
|
{data.PreviousPayments.map((p) => (
|
||||||
<tr key={p.code}>
|
<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>
|
<td className={styles.num}>{formatAmount(p.amount)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Итого ранее</td>
|
<td colSpan={2}>Итого ранее</td>
|
||||||
<td className={styles.num}>{formatAmount(previousTotal)}</td>
|
<td className={styles.num}>{formatAmount(previousTotal)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{opened && opened.id && (
|
||||||
|
<RequestDrawer context={context} itemId={opened.id} listId={opened.listId || ''} code={opened.code} onClose={close} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -108,3 +108,18 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
min-width: 0;
|
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
|
||||||
|
);
|
||||||
+25
-24
@@ -1,6 +1,5 @@
|
|||||||
import { getSP } from '../../../shared/pnp/pnpjs';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { dataListTitle } from '../../../shared/listTitles';
|
import { REQUEST_STATUS, getBasisRequests, getRequestTypeIdByPageUrl } from '../../../shared/approval';
|
||||||
import { REQUEST_STATUS } from '../../../shared/approval';
|
|
||||||
import { FORM_ID, IPreviousPayment } from '../model';
|
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 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 — сама редактируемая заявка: себя в «ранее поданных» показывать нельзя.
|
* 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 [];
|
if (!basisKey) return [];
|
||||||
const safeKey = basisKey.replace(/'/g, "''");
|
const requestTypeId = await getRequestTypeIdByPageUrl(context, FORM_ID);
|
||||||
const rows = (await getSP()
|
const rows = await getBasisRequests(context, requestTypeId, basisKey);
|
||||||
.web.lists.getByTitle(dataListTitle(FORM_ID))
|
|
||||||
.items.select('Id', 'RequestUniqueCode', 'PayAmount', 'RequestStatus')
|
|
||||||
.filter(`BasisKey eq '${safeKey}'`)
|
|
||||||
.top(MAX_PAYMENTS)()) as IPaymentRow[];
|
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
.filter((row) => row.Id !== excludeId && NOT_COUNTED_STATUSES.indexOf(row.RequestStatus || '') === -1)
|
.filter((row) => row.id !== excludeId && NOT_COUNTED_STATUSES.indexOf(row.status) === -1)
|
||||||
.map((row) => ({ code: row.RequestUniqueCode || `№${row.Id}`, amount: Number(row.PayAmount || 0) }));
|
.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 {
|
try {
|
||||||
const info = kind === 'ct' ? await loadCtBasis(context, id) : await loadNeedBasis(context, id);
|
const info = kind === 'ct' ? await loadCtBasis(context, id) : await loadNeedBasis(context, id);
|
||||||
const [previous, projects, companies] = await Promise.all([
|
const [previous, projects, companies] = await Promise.all([
|
||||||
loadPreviousPayments(info.key, editId),
|
loadPreviousPayments(context, info.key, editId),
|
||||||
getProjects(context).catch(() => []),
|
getProjects(context).catch(() => []),
|
||||||
getCompanies(context).catch(() => [])
|
getCompanies(context).catch(() => [])
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ApprovalStatus,
|
ApprovalStatus,
|
||||||
ICurrentListPartition,
|
ICurrentListPartition,
|
||||||
IApiResponse,
|
IApiResponse,
|
||||||
|
IBasisRequest,
|
||||||
IRequestType,
|
IRequestType,
|
||||||
IRequestTypeListSeries,
|
IRequestTypeListSeries,
|
||||||
IResolvedRoute,
|
IResolvedRoute,
|
||||||
@@ -147,6 +148,32 @@ export async function getRequestTypeIdByPageUrl(context: WebPartContext, pageUrl
|
|||||||
return found.id;
|
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'ы, кому в
|
// GET /api/request-type-access/{requestTypeId}/emails — email'ы, кому в
|
||||||
// админке approval-route выдан доступ видеть ВСЕ заявки этого типа (не только
|
// админке approval-route выдан доступ видеть ВСЕ заявки этого типа (не только
|
||||||
// свои). Тот же эндпоинт дёргает и Power Automate — здесь просто ещё один
|
// свои). Тот же эндпоинт дёргает и Power Automate — здесь просто ещё один
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type {
|
|||||||
IListPartition,
|
IListPartition,
|
||||||
ICurrentListPartition,
|
ICurrentListPartition,
|
||||||
IRequestType,
|
IRequestType,
|
||||||
|
IBasisRequest,
|
||||||
IRequestTypeListSeries,
|
IRequestTypeListSeries,
|
||||||
IResolvedRoute,
|
IResolvedRoute,
|
||||||
IResolvedRouteStep,
|
IResolvedRouteStep,
|
||||||
@@ -32,6 +33,7 @@ export {
|
|||||||
getListPartitions,
|
getListPartitions,
|
||||||
getCurrentListPartition,
|
getCurrentListPartition,
|
||||||
getRequestTypeAccessEmails,
|
getRequestTypeAccessEmails,
|
||||||
|
getBasisRequests,
|
||||||
resolveRoute
|
resolveRoute
|
||||||
} from './approvalRoute.service';
|
} from './approvalRoute.service';
|
||||||
export { hasRequestTypeAccess, accessibleAllRequestsTypes, emailsForType } from './requestTypeAccess';
|
export { hasRequestTypeAccess, accessibleAllRequestsTypes, emailsForType } from './requestTypeAccess';
|
||||||
|
|||||||
@@ -126,3 +126,19 @@ export interface IResolvedRouteStep {
|
|||||||
*/
|
*/
|
||||||
positionCode?: string;
|
positionCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заявка, поданная по тому же основанию (СТ, потребность) — GET /api/basis-requests.
|
||||||
|
* Сервер читает их от имени приложения, поэтому видны все, а не только свои;
|
||||||
|
* canOpen — есть ли у текущего пользователя права открыть саму заявку.
|
||||||
|
*/
|
||||||
|
export interface IBasisRequest {
|
||||||
|
id: number;
|
||||||
|
/** Серверный относительный URL списка партиции, где лежит заявка. */
|
||||||
|
listId: string;
|
||||||
|
/** Номер заявки; пока не присвоен — пусто (сервер шлёт null). */
|
||||||
|
uniqueCode?: string;
|
||||||
|
status: string;
|
||||||
|
amount: number;
|
||||||
|
canOpen: boolean;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user