feat: прежние оплаты по основанию берутся из approval-route, ссылка — только при правах

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-28 10:01:34 +05:00
parent 0c0104f6c9
commit 5cfe54f33b
7 changed files with 71 additions and 32 deletions
@@ -142,7 +142,7 @@ const PaymentRequestForm: React.FC<IRequestFormProps> = ({
// ещё правят (черновик, возврат), перечитываем их: по основанию могли // ещё правят (черновик, возврат), перечитываем их: по основанию могли
// подать новые, а лимит суммы должен считаться по актуальным. // подать новые, а лимит суммы должен считаться по актуальным.
if (isEditableStatus(loaded.status) && loaded.data.BasisKey) { if (isEditableStatus(loaded.status) && loaded.data.BasisKey) {
loadPreviousPayments(loaded.data.BasisKey, editId) loadPreviousPayments(context, loaded.data.BasisKey, editId)
.then((previous) => { .then((previous) => {
if (alive) patch({ PreviousPayments: previous }); if (alive) patch({ PreviousPayments: previous });
}) })
@@ -20,7 +20,7 @@ export interface IPreviousPayment {
/** Уникальный код заявки на оплату. */ /** Уникальный код заявки на оплату. */
code: string; code: string;
amount: number; amount: number;
/** Id элемента списка данных — для ссылки на заявку. Нет у снимков, сохранённых до появления ссылок. */ /** Id элемента списка данных — для ссылки на заявку. Нет, если у пользователя нет прав её открыть (или в старом снимке). */
id?: number; id?: number;
/** Список партиции (серверный относительный URL), где лежит заявка; '' — текущая. */ /** Список партиции (серверный относительный URL), где лежит заявка; '' — текущая. */
listId?: string; listId?: string;
@@ -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, currentPartitionForPageUrl } from '../../../shared/approval';
import { FORM_ID, IPreviousPayment } from '../model'; import { FORM_ID, IPreviousPayment } from '../model';
/** /**
@@ -9,39 +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[];
const listId = currentPartitionForPageUrl(FORM_ID)?.listId || '';
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) => ({ .map((row) => ({
code: row.RequestUniqueCode || `№${row.Id}`, code: row.uniqueCode || `№${row.id}`,
amount: Number(row.PayAmount || 0), amount: Number(row.amount || 0),
id: row.Id, id: row.canOpen ? row.id : undefined,
listId, listId: row.listId,
status: row.RequestStatus || '' 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;
}