feat(ct): ссылки на КП на шаге 3 для согласующих; участие в цепочке — по почте

Согласующий по ссылке из Teams попадает сразу на шаг 3, а ссылки на КП были только на шаге 2 —
открыть КП при согласовании было негде. Теперь под таблицей сравнения блок «Коммерческие
предложения» (поставщик → файл). Показывается тем же, кому видны цены: в КП тоже цены.
Участие в цепочке согласования сверялось по ФИО — при другом написании согласующему скрывались
цены (и теперь КП); сверяем по почте без учёта регистра, ФИО — запасной вариант.
This commit is contained in:
Тимур Городецкий
2026-10-01 16:56:40 +05:00
parent fa6d88f2ab
commit 56247fc98f
5 changed files with 94 additions and 4 deletions
@@ -10,6 +10,7 @@ import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTabl
import { Step4Approval } from './Step4Approval/Step4Approval';
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns';
import { buildOffersFromSpRecords } from './utils/spOffers';
import { isUserInApprovalChain } from './utils/approvalAccess';
import { noOfferRecordsToSuppliers, withNoOfferSuppliers } from './utils/noOfferSuppliers';
import styles from './CtCreateForm.module.scss';
import '@tnsu/ui-kit/styles.css';
@@ -33,12 +34,14 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
ensureHighestStep,
} = useCtForm();
// Цены показываем только участникам согласования: рекомендующему (Инициатор) и согласующим.
// Цены и КП показываем только участникам согласования: рекомендующему (Инициатор) и согласующим.
// Автор/инициатор при создании и редактировании СТ (до отправки на согласование) видит цены всегда.
// astel-k (isRestrictedView) — независимое ограничение, складывается с этим: если его уберут,
// правило по цепочке согласования останется. Сопоставление участника — по отображаемому имени
// (пока тестируем; при необходимости переключим на ID).
const userInApprovalChain = (formData.approvalMatrix ?? []).some(m => m.who === userDisplayName);
// правило по цепочке согласования останется. Участника ищем по почте (ФИО — запасной вариант).
const userInApprovalChain = isUserInApprovalChain(
formData.approvalMatrix ?? [],
{ email: service.currentUserEmail, displayName: userDisplayName },
);
const hidePrices = isRestrictedView || (formData.approvalSent === true && !userInApprovalChain);
useEffect(() => {
@@ -0,0 +1,30 @@
import * as React from 'react';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
files: CTFormData['uploadedKpFiles'];
service: CtService;
}
// Ссылки на файлы КП под таблицей сравнения. Согласующий по ссылке из Teams попадает сразу на шаг 3,
// а список КП раньше был только на шаге 2 — открыть КП при согласовании было негде.
export const KpFilesList: React.FC<Props> = ({ files, service }) => {
if (files.length === 0) return null;
return (
<div className={styles.criteriaSection}>
<h4 className={styles.criteriaSectionTitle}>Коммерческие предложения</h4>
<ul className={styles.noOfferSupplierList}>
{files.map(file => (
<li key={file.spId} className={styles.noOfferSupplierItem}>
{file.supplierName && <span className={styles.noOfferSupplierName}>{file.supplierName}</span>}
<a href={service.getKpFileUrl(file.name)} target="_blank" rel="noopener noreferrer">
{file.name}
</a>
</li>
))}
</ul>
</div>
);
};
@@ -13,6 +13,7 @@ import { useStep3Matrix } from './useStep3Matrix';
import { ComparisonMatrix } from './ComparisonMatrix';
import { CriteriaTable } from './CriteriaTable';
import { NoOfferSuppliersList } from './NoOfferSuppliersList';
import { KpFilesList } from './KpFilesList';
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
import { useAdataCheck } from './useAdataCheck';
import { useRecommendedPick } from './useRecommendedPick';
@@ -177,6 +178,9 @@ export const Step3ComparisonTable: React.FC<Props> = ({
<NoOfferSuppliersList entries={getNoOfferSuppliers(formData.suppliers)} />
{/* В КП — цены: показываем тем же, кому видны цены (участникам согласования и автору). */}
{!isRestrictedView && <KpFilesList files={formData.uploadedKpFiles ?? []} service={service} />}
<AiCommentBlock
comment={ai.aiComment}
decodedComment={ai.decodedAiComment}
@@ -0,0 +1,35 @@
import { isUserInApprovalChain } from './approvalAccess';
import { IMatrixRow } from '../../../../shared/approval';
function makeRow(overrides: Partial<IMatrixRow>): IMatrixRow {
return {
order: 1, who: 'Иванов Иван', email: 'ivanov@tnsu.kz', position: 'Директор', status: 'На согласовании',
date: '', comment: '', allowReassign: false, ...overrides,
};
}
describe('isUserInApprovalChain (кому видны цены и КП при согласовании)', () => {
// Раньше сверяли только по ФИО: «Иванов Иван» в SP и «Иван Иванов» в профиле — согласующему
// скрывались цены и КП, хотя он в цепочке.
it('находит согласующего по почте, даже если ФИО записано иначе', () => {
const matrix = [makeRow({ who: 'Иванов Иван' })];
expect(isUserInApprovalChain(matrix, { email: 'IVANOV@tnsu.kz', displayName: 'Иван Иванов' })).toBe(true);
});
it('без почты в строке — сверяет по ФИО', () => {
const matrix = [makeRow({ email: '' })];
expect(isUserInApprovalChain(matrix, { email: 'ivanov@tnsu.kz', displayName: 'Иванов Иван' })).toBe(true);
});
it('не в цепочке — false', () => {
const matrix = [makeRow({})];
expect(isUserInApprovalChain(matrix, { email: 'petrov@tnsu.kz', displayName: 'Петров Пётр' })).toBe(false);
});
it('пустая цепочка — false', () => {
expect(isUserInApprovalChain([], { email: 'ivanov@tnsu.kz', displayName: 'Иванов Иван' })).toBe(false);
});
});
@@ -0,0 +1,18 @@
import { IMatrixRow } from '../../../../shared/approval';
export interface CurrentUser {
email: string;
displayName: string;
}
// Участник цепочки согласования — ему видны цены и КП. Сверяем по почте (без учёта регистра): ФИО в SP
// и в профиле пишут по-разному («Иванов Иван» / «Иван Иванов»), и по одному ФИО согласующему цены и КП
// скрывались. ФИО — запасной вариант для строк без почты.
export function isUserInApprovalChain(matrix: IMatrixRow[], user: CurrentUser): boolean {
const email = user.email.trim().toLowerCase();
return matrix.some(row => {
const rowEmail = (row.email ?? '').trim().toLowerCase();
if (rowEmail && email) return rowEmail === email;
return row.who === user.displayName;
});
}