feat(ct): поставщики без КП — списком под таблицей сравнения, в т.ч. у согласующего

Поставщиков без КП убрали из колонок (64973a2), но взамен нигде не показывали. Теперь на
шаге 3 под «Дополнительной информацией» — список «Поставщики, не предоставившие КП» с
комментарием. «Не получены КП» сохраняются с привязкой к СТ (колонка CT была в списке, но не
заполнялась, а дедуп шёл по названию на все СТ) и синхронизируются по «Сохранить» на шагах
2 и 3 — удалённые убираются; по ссылке согласующий видит список из SP. Комментарий больше
не теряется при возврате на шаг 2.
This commit is contained in:
Тимур Городецкий
2026-10-01 15:34:03 +05:00
parent efedfc7658
commit f5853b1ad5
11 changed files with 236 additions and 21 deletions
@@ -10,6 +10,7 @@ import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTabl
import { Step4Approval } from './Step4Approval/Step4Approval'; import { Step4Approval } from './Step4Approval/Step4Approval';
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns'; import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns';
import { buildOffersFromSpRecords } from './utils/spOffers'; import { buildOffersFromSpRecords } from './utils/spOffers';
import { noOfferRecordsToSuppliers, withNoOfferSuppliers } from './utils/noOfferSuppliers';
import styles from './CtCreateForm.module.scss'; import styles from './CtCreateForm.module.scss';
import '@tnsu/ui-kit/styles.css'; import '@tnsu/ui-kit/styles.css';
@@ -65,6 +66,13 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
]).then(async ([ct, approvalMatrix, positions]) => { ]).then(async ([ct, approvalMatrix, positions]) => {
const positionSpIds = positions.map(p => p.spId).filter((id): id is number => id !== undefined); const positionSpIds = positions.map(p => p.spId).filter((id): id is number => id !== undefined);
const rawOffers = await service.loadFullOffersForPositions(positionSpIds); const rawOffers = await service.loadFullOffersForPositions(positionSpIds);
// Поставщики без КП этой СТ — для списка под таблицей сравнения (согласующий по ссылке).
const noOfferFromSp = noOfferRecordsToSuppliers(
await service.loadNoOfferSuppliers(initialCtId).catch(err => {
console.warn('[CtCreateForm] не удалось загрузить поставщиков без КП:', err);
return [];
}),
);
// Build supplierSpId → offerId map from session uploadedKpFiles. // Build supplierSpId → offerId map from session uploadedKpFiles.
// Лукап offerId через REST не читается (500), поэтому сначала берём из сессии, а без сессии — // Лукап offerId через REST не читается (500), поэтому сначала берём из сессии, а без сессии —
@@ -152,7 +160,7 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
...(isNotificationLink || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}), ...(isNotificationLink || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}),
// Preserve session suppliers and offers — SP data may be incomplete or stale vs. unsaved edits. // Preserve session suppliers and offers — SP data may be incomplete or stale vs. unsaved edits.
// Only use SP-derived data when session has none, or when opened via notification link (always fresh). // Only use SP-derived data when session has none, or when opened via notification link (always fresh).
...(formData.suppliers.length === 0 ? { suppliers: spSuppliers } : {}), suppliers: withNoOfferSuppliers(formData.suppliers.length === 0 ? spSuppliers : formData.suppliers, noOfferFromSp),
...(isNotificationLink || formData.offers.length === 0 ? { offers } : {}), ...(isNotificationLink || formData.offers.length === 0 ? { offers } : {}),
...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}), ...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}),
...(isNotificationLink ? { approvalSent: true } : {}), ...(isNotificationLink ? { approvalSent: true } : {}),
@@ -45,7 +45,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
const [noOfferSuppliers, setNoOfferSuppliers] = useState<NoOfferEntry[]>(() => const [noOfferSuppliers, setNoOfferSuppliers] = useState<NoOfferEntry[]>(() =>
(formData.suppliers ?? []) (formData.suppliers ?? [])
.filter(s => !s.hasOffer) .filter(s => !s.hasOffer)
.map(s => ({ localId: s.id, name: s.name, comment: '' })), .map(s => ({ localId: s.id, name: s.name, comment: s.comment ?? '' })),
); );
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined); const [saveError, setSaveError] = useState<string | undefined>(undefined);
@@ -70,6 +70,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
id: s.localId, id: s.localId,
name: s.name, name: s.name,
hasOffer: false, hasOffer: false,
comment: s.comment,
})); }));
onSave({ suppliers: [...withOffer, ...withoutOffer] }); onSave({ suppliers: [...withOffer, ...withoutOffer] });
}; };
@@ -3,6 +3,7 @@ import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm'; import { CTFormData } from '../hooks/useCtForm';
import { KpFile, NoOfferEntry } from './kpTypes'; import { KpFile, NoOfferEntry } from './kpTypes';
import { buildSupplierColumnId } from '../utils/supplierColumns'; import { buildSupplierColumnId } from '../utils/supplierColumns';
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
export interface PersistStep2Args { export interface PersistStep2Args {
service: CtService; service: CtService;
@@ -76,16 +77,13 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
savedRates = await service.saveCurrencyRates(currencyRates); savedRates = await service.saveCurrencyRates(currencyRates);
} }
for (const s of noOfferSuppliers) {
await service.saveNoOfferSupplierItem(s.name, s.comment);
}
const { suppliers: newSuppliersWithOffer, offers: newOffers } = buildKpSuppliersAndOffers(kpFiles, savedRates); const { suppliers: newSuppliersWithOffer, offers: newOffers } = buildKpSuppliersAndOffers(kpFiles, savedRates);
const suppliersWithoutOffer: Supplier[] = noOfferSuppliers.map(s => ({ const suppliersWithoutOffer: Supplier[] = noOfferSuppliers.map(s => ({
id: s.localId, id: s.localId,
name: s.name, name: s.name,
hasOffer: false, hasOffer: false,
comment: s.comment,
})); }));
// Persist offers with currency data to SP so shared links show correct currency columns. // Persist offers with currency data to SP so shared links show correct currency columns.
@@ -128,9 +126,15 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
// API, см. план миграции согласования) маршрут — это планируемый resolve, который Step4Approval // API, см. план миграции согласования) маршрут — это планируемый resolve, который Step4Approval
// резолвит «на лету» при открытии шага (CtService.getPlannedApprovalMatrix), писать в SP заранее не нужно. // резолвит «на лету» при открытии шага (CtService.getPlannedApprovalMatrix), писать в SP заранее не нужно.
// «Не получены КП» — с привязкой к СТ: по ссылке согласующий видит список под таблицей сравнения.
const allNoOfferSuppliers = [...preservedNoOfferSuppliers, ...suppliersWithoutOffer];
if (formData.ct) {
await service.syncNoOfferSuppliers(formData.ct.id, getNoOfferSuppliers(allNoOfferSuppliers));
}
return { return {
currencyRates: savedRates, currencyRates: savedRates,
suppliers: [...mergedSuppliersWithOffer, ...preservedNoOfferSuppliers, ...suppliersWithoutOffer], suppliers: [...mergedSuppliersWithOffer, ...allNoOfferSuppliers],
offers: mergedOffers, offers: mergedOffers,
}; };
} }
@@ -0,0 +1,26 @@
import * as React from 'react';
import { NoOfferSupplierEntry } from '../utils/noOfferSuppliers';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
entries: NoOfferSupplierEntry[];
}
// Поставщики, не предоставившие КП (Шаг 2), — списком под таблицей сравнения. Колонками их не
// показываем: сравнивать у них нечего, а список нужен согласующим — к кому обращались.
export const NoOfferSuppliersList: React.FC<Props> = ({ entries }) => {
if (entries.length === 0) return null;
return (
<div className={styles.criteriaSection}>
<h4 className={styles.criteriaSectionTitle}>Поставщики, не предоставившие КП</h4>
<ul className={styles.noOfferSupplierList}>
{entries.map(entry => (
<li key={entry.name} className={styles.noOfferSupplierItem}>
<span className={styles.noOfferSupplierName}>{entry.name}</span>
{entry.comment && <span className={styles.noOfferSupplierComment}>{entry.comment}</span>}
</li>
))}
</ul>
</div>
);
};
@@ -592,6 +592,32 @@
overflow-x: auto; overflow-x: auto;
} }
// «Поставщики, не предоставившие КП» под таблицей (NoOfferSuppliersList) — в рамке как «Доп. информация».
.noOfferSupplierList {
list-style: none;
margin: 0;
padding: 8px 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.noOfferSupplierItem {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
font-size: 13px;
}
.noOfferSupplierName {
font-weight: 600;
color: $text;
}
.noOfferSupplierComment {
color: $muted;
}
.criteriaTable { .criteriaTable {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
@@ -12,6 +12,8 @@ import { AiCommentBlock } from './AiCommentBlock';
import { useStep3Matrix } from './useStep3Matrix'; import { useStep3Matrix } from './useStep3Matrix';
import { ComparisonMatrix } from './ComparisonMatrix'; import { ComparisonMatrix } from './ComparisonMatrix';
import { CriteriaTable } from './CriteriaTable'; import { CriteriaTable } from './CriteriaTable';
import { NoOfferSuppliersList } from './NoOfferSuppliersList';
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
import { useAdataCheck } from './useAdataCheck'; import { useAdataCheck } from './useAdataCheck';
import { useRecommendedPick } from './useRecommendedPick'; import { useRecommendedPick } from './useRecommendedPick';
import { VendorPickerDialog } from './VendorPickerDialog'; import { VendorPickerDialog } from './VendorPickerDialog';
@@ -171,6 +173,8 @@ export const Step3ComparisonTable: React.FC<Props> = ({
<CriteriaTable matrix={matrix} /> <CriteriaTable matrix={matrix} />
<NoOfferSuppliersList entries={getNoOfferSuppliers(formData.suppliers)} />
<AiCommentBlock <AiCommentBlock
comment={ai.aiComment} comment={ai.aiComment}
decodedComment={ai.decodedAiComment} decodedComment={ai.decodedAiComment}
@@ -7,6 +7,7 @@ import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, Cr
import { applyCreatedIds, buildOfferKey, computeOffer } from './matrixHelpers'; import { applyCreatedIds, buildOfferKey, computeOffer } from './matrixHelpers';
import { IVendorRecord } from '../../services/VendorDatabaseService'; import { IVendorRecord } from '../../services/VendorDatabaseService';
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns'; import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
let extraIdCounter = 0; let extraIdCounter = 0;
@@ -64,6 +64,7 @@ export interface Supplier {
inn?: string; // UI only — not stored in SP inn?: string; // UI only — not stored in SP
bin?: string; // БИН из флоу (Adata) — хранится в SP-поле 'bin' bin?: string; // БИН из флоу (Adata) — хранится в SP-поле 'bin'
hasOffer?: boolean; // determines target list: Поставщики vs Не получены КП hasOffer?: boolean; // determines target list: Поставщики vs Не получены КП
comment?: string; // только у поставщика без КП (hasOffer: false) — почему нет КП
status?: string; status?: string;
currency?: string; // currency code for KP (KZT by default); stored here so Step 3 can show correct columns even if offers are empty currency?: string; // currency code for KP (KZT by default); stored here so Step 3 can show correct columns even if offers are empty
adataData?: AdataCompanyInfo; // кэш последней проверки Adata — SP-поле 'adataData' (JSON) adataData?: AdataCompanyInfo; // кэш последней проверки Adata — SP-поле 'adataData' (JSON)
@@ -0,0 +1,77 @@
import { getNoOfferSuppliers, noOfferRecordsToSuppliers, planNoOfferSync, withNoOfferSuppliers } from './noOfferSuppliers';
import { Supplier } from '../types/domain';
describe('planNoOfferSync («Не получены КП» по одной СТ)', () => {
const existing = [
{ id: 1, name: 'ТОО БЕЗ КП ТЕСТ', comment: 'НЕ ТРОГАТЬ, ТЕСТ!!!!!' },
{ id: 2, name: 'ТОО Asia Techno', comment: 'не работают с маленьким объемом' },
];
it('новый поставщик — создать', () => {
const plan = planNoOfferSync(existing, [
...existing.map(e => ({ name: e.name, comment: e.comment })),
{ name: 'ТОО «Новый»', comment: '' },
]);
expect(plan.toCreate).toEqual([{ name: 'ТОО «Новый»', comment: '' }]);
expect(plan.toRemove).toEqual([]);
});
// Удалили на шаге 2 — должен уйти и из SP, иначе согласующий по ссылке увидит его снова.
it('удалённый на шаге 2 — убрать', () => {
const plan = planNoOfferSync(existing, [{ name: 'ТОО БЕЗ КП ТЕСТ', comment: 'НЕ ТРОГАТЬ, ТЕСТ!!!!!' }]);
expect(plan.toRemove).toEqual([2]);
expect(plan.toCreate).toEqual([]);
});
it('изменённый комментарий — пересоздать запись', () => {
const plan = planNoOfferSync(existing, [
{ name: 'ТОО БЕЗ КП ТЕСТ', comment: 'отказались' },
{ name: 'ТОО Asia Techno', comment: 'не работают с маленьким объемом' },
]);
expect(plan.toRemove).toEqual([1]);
expect(plan.toCreate).toEqual([{ name: 'ТОО БЕЗ КП ТЕСТ', comment: 'отказались' }]);
});
it('пробелы по краям названия и комментария не считаются изменением', () => {
const plan = planNoOfferSync(existing, [
{ name: ' ТОО БЕЗ КП ТЕСТ ', comment: 'НЕ ТРОГАТЬ, ТЕСТ!!!!! ' },
{ name: 'ТОО Asia Techno', comment: 'не работают с маленьким объемом' },
]);
expect(plan).toEqual({ toCreate: [], toRemove: [] });
});
});
describe('getNoOfferSuppliers', () => {
it('поставщики без КП для списка под таблицей сравнения', () => {
const suppliers: Supplier[] = [
{ id: '263_277', spId: 263, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true },
{ id: 'noOffer_1', name: 'ТОО БЕЗ КП ТЕСТ', hasOffer: false, comment: 'НЕ ТРОГАТЬ' },
];
expect(getNoOfferSuppliers(suppliers)).toEqual([{ name: 'ТОО БЕЗ КП ТЕСТ', comment: 'НЕ ТРОГАТЬ' }]);
});
});
describe('поставщики без КП при открытии по ссылке', () => {
const fromSp = noOfferRecordsToSuppliers([{ id: 28, name: 'ТОО БЕЗ КП ТЕСТ', comment: 'НЕ ТРОГАТЬ' }]);
it('записи SP становятся поставщиками без КП', () => {
expect(fromSp).toEqual([{ id: 'noOffer_28', name: 'ТОО БЕЗ КП ТЕСТ', hasOffer: false, comment: 'НЕ ТРОГАТЬ' }]);
});
it('добавляются к колонкам с КП', () => {
const withOffer: Supplier[] = [{ id: '263_277', spId: 263, name: 'Арлан', hasOffer: true }];
expect(withNoOfferSuppliers(withOffer, fromSp).map(s => s.id)).toEqual(['263_277', 'noOffer_28']);
});
it('свои поставщики без КП в сессии не заменяются', () => {
const session: Supplier[] = [{ id: 'local_1', name: 'Из сессии', hasOffer: false }];
expect(withNoOfferSuppliers(session, fromSp)).toEqual(session);
});
});
@@ -0,0 +1,47 @@
import { Supplier } from '../types/domain';
// Поставщик, не предоставивший КП: название и комментарий (почему нет КП).
export interface NoOfferSupplierEntry {
name: string;
comment: string;
}
// Запись списка «Не получены КП» этой СТ в SP.
export interface NoOfferSupplierRecord extends NoOfferSupplierEntry {
id: number;
}
const sameEntry = (a: NoOfferSupplierEntry, b: NoOfferSupplierEntry): boolean =>
a.name.trim() === b.name.trim() && a.comment.trim() === b.comment.trim();
// Синхронизация «Не получены КП» одной СТ с тем, что сейчас на шаге 2: новых — создать, удалённых и
// изменённых (другой комментарий) — убрать, изменённых — создать заново. Раньше записи только
// добавлялись и без привязки к СТ, поэтому по ссылке список было не восстановить.
export function planNoOfferSync(
existing: NoOfferSupplierRecord[],
desired: NoOfferSupplierEntry[],
): { toCreate: NoOfferSupplierEntry[]; toRemove: number[] } {
return {
toCreate: desired.filter(d => !existing.some(e => sameEntry(e, d))),
toRemove: existing.filter(e => !desired.some(d => sameEntry(e, d))).map(e => e.id),
};
}
// Поставщики без КП из колонок формы — для списка под таблицей сравнения (Шаг 3).
export function getNoOfferSuppliers(suppliers: Supplier[]): NoOfferSupplierEntry[] {
return suppliers
.filter(s => s.hasOffer === false)
.map(s => ({ name: s.name, comment: s.comment ?? '' }));
}
// Поставщики без КП из SP (открытие по ссылке / без сессии) — в колонки формы с hasOffer: false.
// Префикс id — чтобы не пересечься с id колонок поставщиков с КП.
export function noOfferRecordsToSuppliers(records: NoOfferSupplierRecord[]): Supplier[] {
return records.map(r => ({ id: `noOffer_${r.id}`, name: r.name, hasOffer: false, comment: r.comment }));
}
// Колонки формы с поставщиками без КП из SP: сессию не трогаем, если в ней уже есть свои без КП.
export function withNoOfferSuppliers(current: Supplier[], fromSp: Supplier[]): Supplier[] {
if (fromSp.length === 0 || current.some(s => s.hasOffer === false)) return current;
return [...current, ...fromSp];
}
@@ -30,6 +30,11 @@ import {
keepLatestOfferRecords, keepLatestOfferRecords,
parseOfferItemIndex, parseOfferItemIndex,
} from "../../../shared/offers/offerRecords"; } from "../../../shared/offers/offerRecords";
import {
NoOfferSupplierEntry,
NoOfferSupplierRecord,
planNoOfferSync,
} from "../components/utils/noOfferSuppliers";
// Round monetary values to 2 decimals before persisting to SharePoint (drops float tails). // Round monetary values to 2 decimals before persisting to SharePoint (drops float tails).
function round2(value: number): number { function round2(value: number): number {
@@ -1288,23 +1293,38 @@ export class CtService {
return { spId, offerId }; return { spId, offerId };
} }
async saveNoOfferSupplierItem(name: string, comment?: string): Promise<void> { // «Не получены КП» этой СТ (привязка по лукапу CT). Раньше записи сохранялись без СТ и с дедупом
const escaped = name.replace(/'/g, "''"); // по названию на все СТ сразу — по ссылке список поставщиков без КП было не восстановить.
const checkRes = await this.http.get( async loadNoOfferSuppliers(ctId: string): Promise<NoOfferSupplierRecord[]> {
`${this._li(LISTS.NO_OFFERS)}?$filter=Title eq '${escaped}'&$select=Id&$top=1`, const res = await this.http.get(
`${this._li(LISTS.NO_OFFERS)}?$select=Id,Title,comment&$filter=CTId eq ${parseInt(ctId, 10)}&$top=500`,
SPHttpClient.configurations.v1, SPHttpClient.configurations.v1,
); );
if (checkRes.ok && ((await checkRes.json()).value ?? []).length > 0) return; if (!res.ok) throw new Error(`Ошибка чтения поставщиков без КП: ${res.status}`);
const res = await this.http.post( const data = await res.json();
this._li(LISTS.NO_OFFERS), return ((data.value ?? []) as Array<Record<string, unknown>>).map((item) => ({
SPHttpClient.configurations.v1, id: Number(item.Id),
{ name: String(item.Title ?? ""),
comment: String(item.comment ?? ""),
}));
}
// Приводит «Не получены КП» этой СТ к списку с шага 2: новых — создать, удалённых и изменённых — убрать.
async syncNoOfferSuppliers(ctId: string, entries: NoOfferSupplierEntry[]): Promise<void> {
const existing = await this.loadNoOfferSuppliers(ctId);
const { toCreate, toRemove } = planNoOfferSync(existing, entries);
await this._runChunked(toRemove, 20, async (id) => {
await this.http
.post(`${this._liById(LISTS.NO_OFFERS, id)}/recycle()`, SPHttpClient.configurations.v1, {})
.catch(() => undefined /* уже в корзине / не найден — игнорируем */);
});
for (const entry of toCreate) {
const res = await this.http.post(this._li(LISTS.NO_OFFERS), SPHttpClient.configurations.v1, {
headers: { "Content-Type": "application/json;odata=nometadata" }, headers: { "Content-Type": "application/json;odata=nometadata" },
body: JSON.stringify({ Title: name, comment: comment ?? "" }), body: JSON.stringify({ Title: entry.name.trim(), comment: entry.comment.trim(), CTId: parseInt(ctId, 10) }),
}, });
); if (!res.ok) throw new Error(`Ошибка сохранения поставщика без КП: ${res.status}`);
if (!res.ok) }
throw new Error(`Ошибка сохранения поставщика без КП: ${res.status}`);
} }
async getCurrentUserProfile(): Promise<{ async getCurrentUserProfile(): Promise<{