feat(ct): выбор поставщика из базы данных на шаге СТ

This commit is contained in:
Тимур Городецкий
2026-09-17 16:23:56 +05:00
parent 7e403a8623
commit adf3efeb64
10 changed files with 508 additions and 7 deletions
@@ -781,13 +781,13 @@
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
flex-wrap: wrap;
padding: 4px 0 8px;
max-width: 30%;
> :first-child {
flex: 1 1 0;
min-width: 0;
min-width: 160px;
}
}
@@ -1,7 +1,9 @@
import * as React from 'react';
import { useMemo, useState } from 'react';
import { Button, Alert, TextInput, Toggle, Spinner, Dialog, Icon } from '@tnsu/ui-kit';
import { FormStep } from '../types/domain';
import { CtService } from '../../services/CtService';
import { VendorDatabaseService, IVendorRecord } from '../../services/VendorDatabaseService';
import { CTFormData } from '../hooks/useCtForm';
import styles from './Step3ComparisonTable.module.scss';
import { InlineApprovalChain } from './InlineApprovalChain';
@@ -12,6 +14,7 @@ import { ComparisonMatrix } from './ComparisonMatrix';
import { CriteriaTable } from './CriteriaTable';
import { useAdataCheck } from './useAdataCheck';
import { useRecommendedPick } from './useRecommendedPick';
import { VendorPickerDialog } from './VendorPickerDialog';
interface Props {
service: CtService;
@@ -42,13 +45,22 @@ export const Step3ComparisonTable: React.FC<Props> = ({
const matrix = useStep3Matrix({ service, formData, onSave, onNext, onStepComplete });
const {
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
handleAddSupplier, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave,
handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave,
extraPositions, suppliersWithOffer, allPositions, computedOffers, selectedSupplierId,
} = matrix;
// Проверка контрагентов в Adata вынесена в хук.
const adata = useAdataCheck(service, suppliersWithOffer);
// Модалка выбора поставщика из «База данных поставщиков» (см. VendorPickerDialog).
const [showVendorPicker, setShowVendorPicker] = useState(false);
const vendorDbService = useMemo(() => new VendorDatabaseService(service.webPartContext), [service]);
const handleAddVendorFromDatabase = (vendor: IVendorRecord): void => {
const id = handleAddSupplierFromDatabase(vendor);
if (vendor.bin) adata.seedBin(id, vendor.bin);
setShowVendorPicker(false);
};
// Выбор рекомендованного поставщика по формуле 80% цена / 20% условия оплаты вынесен в хук.
const recPick = useRecommendedPick(service, matrix);
@@ -112,6 +124,10 @@ export const Step3ComparisonTable: React.FC<Props> = ({
<Icon name="plus" size="sm" />
Добавить потенциального поставщика
</Button>
<Button variant="secondary" onClick={() => setShowVendorPicker(true)}>
<Icon name="building" size="sm" />
Добавить из базы поставщиков
</Button>
</div>
)}
@@ -210,6 +226,14 @@ export const Step3ComparisonTable: React.FC<Props> = ({
</div>
</div>
{showVendorPicker && (
<VendorPickerDialog
vendorDbService={vendorDbService}
onAdd={handleAddVendorFromDatabase}
onClose={() => setShowVendorPicker(false)}
/>
)}
{ai.showAiPromptDialog && (
<Dialog
open
@@ -0,0 +1,105 @@
@import '../common/theme';
.state {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0;
color: $muted;
font-size: 14px;
}
.filterRow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.searchBox {
flex: 1 1 auto;
min-width: 0;
}
.categoryBox {
flex: 0 0 200px;
max-width: 200px;
// Select из ui-kit не принимает className — форсируем ширину нативного <select> под обёртку.
:global(select) {
width: 100%;
}
}
.hint {
margin-bottom: 8px;
font-size: 12px;
color: $muted;
}
.list {
list-style: none;
margin: 0;
padding: 4px;
width: 100%;
max-height: 50vh;
overflow-y: auto;
box-sizing: border-box;
border: 1px solid $border-strong;
border-radius: 4px;
background: $surface;
display: flex;
flex-direction: column;
gap: 4px;
}
.item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border-radius: 4px;
cursor: pointer;
&:hover {
background: $hover;
}
}
.itemMain {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.itemName {
font-weight: 600;
color: $text;
}
.itemBin {
color: $muted;
font-size: 12px;
white-space: nowrap;
}
.itemMeta {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
color: $muted;
}
// ─── Карточка поставщика (read-only значения полей) ─────────────────────────
.viewValue {
min-height: 32px;
display: flex;
align-items: center;
padding: 6px 0;
color: $text;
font-size: 14px;
overflow-wrap: anywhere;
}
@@ -0,0 +1,176 @@
import * as React from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Dialog, Button, TextInput, Select, Spinner, Alert, FormGrid, FormField, Icon } from '@tnsu/ui-kit';
import { VendorDatabaseService, IVendorRecord } from '../../services/VendorDatabaseService';
import styles from './VendorPickerDialog.module.scss';
interface Props {
vendorDbService: VendorDatabaseService;
onAdd: (vendor: IVendorRecord) => void;
onClose: () => void;
}
// Список большой (сотни поставщиков) — без поиска и без выбранного раздела показываем только
// первые N по алфавиту (сортировка уже приходит из VendorDatabaseService.listVendors), чтобы окно
// не было пустым при открытии, но и не рендерило все 500+ строк разом.
const INITIAL_LIMIT = 20;
function ViewField({ label, value, full }: { label: string; value: string; full?: boolean }): JSX.Element {
return (
<FormField label={label} full={full}>
<div className={styles.viewValue}>{value || '—'}</div>
</FormField>
);
}
// Диалог выбора поставщика из «База данных поставщиков» (Шаг 3 СТ): поиск + список → карточка
// поставщика с кнопкой добавления в таблицу сравнения (см. handleAddSupplierFromDatabase).
export const VendorPickerDialog: React.FC<Props> = ({ vendorDbService, onAdd, onClose }) => {
const [vendors, setVendors] = useState<IVendorRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [category, setCategory] = useState('');
const [selected, setSelected] = useState<IVendorRecord | null>(null);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
vendorDbService
.listVendors()
.then((list) => { if (!cancelled) setVendors(list); })
.catch((e) => { if (!cancelled) setError((e as Error).message || 'Ошибка загрузки базы поставщиков'); })
.finally(() => { if (!cancelled) setIsLoading(false); });
return () => { cancelled = true; };
}, [vendorDbService]);
const categoryOptions = useMemo(() => {
const unique = Array.from(new Set(vendors.map((v) => v.category).filter(Boolean)));
unique.sort((a, b) => a.localeCompare(b, 'ru'));
return unique.map((c) => ({ value: c, label: c }));
}, [vendors]);
const isBrowsing = !query.trim() && !category;
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const byCategory = category ? vendors.filter((v) => v.category === category) : vendors;
if (!q) return byCategory;
return byCategory.filter(
(v) =>
v.name.toLowerCase().includes(q) ||
v.bin.includes(q) ||
v.category.toLowerCase().includes(q),
);
}, [vendors, query, category]);
// Без поиска и без раздела — режем список, чтобы не рендерить всё сразу; при любом сужении
// (текст и/или раздел) пользователь уже сам ограничил выборку — показываем всё, что нашлось.
const displayed = isBrowsing ? filtered.slice(0, INITIAL_LIMIT) : filtered;
if (selected) {
return (
<Dialog
open
title="Карточка поставщика"
subText={selected.category ? `Раздел: ${selected.category}` : undefined}
onDismiss={onClose}
width="lg"
footer={
<>
<Button variant="ghost" onClick={() => setSelected(null)}>
<Icon name="arrow-left" size="sm" />
Назад к списку
</Button>
<Button variant="primary" onClick={() => onAdd(selected)}>
<Icon name="plus" size="sm" />
Добавить поставщика в СТ
</Button>
</>
}
>
<FormGrid>
<ViewField label="Наименование" value={selected.name} full />
<ViewField label="БИН" value={selected.bin} />
<ViewField label="Статус" value={selected.status} />
<ViewField label="Юридический адрес" value={selected.legalAddress} full />
<ViewField label="Факт. адрес / склад" value={selected.actualAddress} full />
<ViewField label="Контактное лицо" value={selected.contactPerson} />
<ViewField label="Контактная информация" value={selected.contactInfo} />
<ViewField label="Email" value={selected.email} />
<ViewField label="Веб-сайт" value={selected.website} />
{selected.productName && <ViewField label="Наименование ТМЦ" value={selected.productName} full />}
{selected.catalogLink && <ViewField label="Ссылка на каталог/прайс" value={selected.catalogLink} full />}
</FormGrid>
</Dialog>
);
}
return (
<Dialog
open
title="Выбор поставщика из базы"
subText="Найдите поставщика по названию, БИН или разделу — выбор откроет его карточку."
onDismiss={onClose}
width="lg"
>
{isLoading && (
<div className={styles.state}>
<Spinner size="small" /> Загрузка базы поставщиков…
</div>
)}
{!isLoading && error && <Alert variant="err">{error}</Alert>}
{!isLoading && !error && (
<>
<div className={styles.filterRow}>
<div className={styles.searchBox}>
<TextInput
value={query}
onChange={setQuery}
placeholder="Поиск по названию или БИН…"
ariaLabel="Поиск поставщика"
autoFocus
/>
</div>
<div className={styles.categoryBox}>
<Select
value={category}
onChange={setCategory}
options={categoryOptions}
placeholder="Все разделы"
/>
</div>
</div>
{filtered.length === 0 ? (
<div className={styles.state}>Ничего не найдено</div>
) : (
<>
{isBrowsing && vendors.length > INITIAL_LIMIT && (
<div className={styles.hint}>
Показаны первые {INITIAL_LIMIT} из {vendors.length} — используйте поиск или выберите раздел
</div>
)}
<ul className={styles.list}>
{displayed.map((v) => (
<li key={v.id} className={styles.item} onClick={() => setSelected(v)}>
<div className={styles.itemMain}>
<span className={styles.itemName}>{v.name}</span>
{v.bin && <span className={styles.itemBin}>БИН {v.bin}</span>}
</div>
<div className={styles.itemMeta}>
<span>{v.category || '—'}</span>
{v.contactInfo && <span>{v.contactInfo}</span>}
</div>
</li>
))}
</ul>
</>
)}
</>
)}
</Dialog>
);
};
@@ -6,6 +6,14 @@ export interface ExtraSupplier {
currency: string;
spId?: number;
status?: string;
// Заполняются только когда поставщик добавлен из VendorPickerDialog («База данных поставщиков»).
offerId?: number;
bin?: string;
address?: string;
contactPerson?: string;
contactInfo?: string;
email?: string;
website?: string;
}
export interface ExtraPosition {
@@ -15,6 +15,7 @@ export interface UseAdataCheckResult {
checkSupplier: (supplierId: string) => Promise<void>;
checkAllSuppliers: () => Promise<void>;
isCheckingAll: boolean;
seedBin: (supplierId: string, bin: string) => void;
}
// Проверка контрагентов в Adata (Шаг 3): подтягивает БИН + кэш прошлой проверки из списка
@@ -82,5 +83,12 @@ export function useAdataCheck(service: CtService, suppliers: Supplier[]): UseAda
setIsCheckingAll(false);
}, [checkSupplier]);
return { adataMap, checkSupplier, checkAllSuppliers, isCheckingAll };
// Проставляет БИН сразу (без ожидания эффекта монтирования выше, у которого deps=[] и который
// не подхватит поставщика, добавленного уже после первого рендера) — нужен для поставщика,
// выбранного из VendorPickerDialog, чтобы «Проверить всех»/иконка Adata стали доступны сразу.
const seedBin = useCallback((supplierId: string, bin: string): void => {
setAdataMap(prev => ({ ...prev, [supplierId]: { ...prev[supplierId], bin, isLoading: false } }));
}, []);
return { adataMap, checkSupplier, checkAllSuppliers, isCheckingAll, seedBin };
}
@@ -5,6 +5,7 @@ import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
import { buildOfferKey, computeOffer } from './matrixHelpers';
import { IVendorRecord } from '../../services/VendorDatabaseService';
let extraIdCounter = 0;
@@ -290,7 +291,12 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
const suppliersWithOffer = useMemo(() => [
...suppliers.map(s => ({ ...s, isExtraSupplier: false as const, isNoOffer: !s.hasOffer })),
...extraSuppliers.map(es => ({ id: es.id, name: es.name, hasOffer: true as const, isExtraSupplier: true as const, isNoOffer: false as const })),
...extraSuppliers.map(es => ({
id: es.id, name: es.name, hasOffer: true as const, isExtraSupplier: true as const, isNoOffer: false as const,
// spId/bin известны сразу для поставщиков, добавленных из «База данных поставщиков»
// (см. handleAddSupplierFromDatabase) — без этого useAdataCheck не увидит их БИН.
spId: es.spId, bin: es.bin,
})),
], [suppliers, extraSuppliers]);
const allPositions = useMemo((): PositionRow[] => {
@@ -564,6 +570,58 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
onSave({ step3ExtraSuppliers: updated });
};
// Добавление поставщика из VendorPickerDialog («База данных поставщиков»): та же механика
// колонки, что и handleAddSupplier, но с известными БИН/контактами. В отличие от ручного ввода,
// сразу (не дожидаясь «Сохранить и продолжить») пишем запись в SP «Поставщики» — по аналогии
// с потоком «поставщик с КП», который тоже сохраняет eagerly. Это даёт непустой БИН сразу, и
// кнопка «Проверить всех» (Adata) становится доступна без ожидания сохранения формы.
const handleAddSupplierFromDatabase = (vendor: IVendorRecord): string => {
const id = `extra_sup_${++extraIdCounter}`;
const address = vendor.legalAddress || vendor.actualAddress || undefined;
const newEntry: ExtraSupplier = {
id,
name: vendor.name,
currency: 'KZT',
bin: vendor.bin || undefined,
address,
contactPerson: vendor.contactPerson || undefined,
contactInfo: vendor.contactInfo || undefined,
email: vendor.email || undefined,
website: vendor.website || undefined,
};
const updated = [...extraSuppliers, newEntry];
setExtraSuppliers(updated);
setSupplierStatuses(prev => ({ ...prev, [id]: '' }));
onSave({ step3ExtraSuppliers: updated });
void (async (): Promise<void> => {
try {
const { spId, offerId } = await service.saveManualSupplierWithKp(vendor.name);
await service.saveSupplierContacts(spId, {
bin: vendor.bin || undefined,
address,
contactPerson: vendor.contactPerson || undefined,
contactInfo: vendor.contactInfo || undefined,
email: vendor.email || undefined,
website: vendor.website || undefined,
});
setExtraSuppliers(prev => {
const next = prev.map(es => (es.id === id ? { ...es, spId, offerId } : es));
onSave({ step3ExtraSuppliers: next });
return next;
});
} catch (err) {
// Колонку не блокируем — при неудаче запись в SP создастся позже как обычно при handleSave
// (уже без предзаполненных БИН/контактов, как для вручную введённого поставщика).
setSaveError(
`Не удалось сразу сохранить «${vendor.name}» в SharePoint — данные досохранятся при нажатии «Сохранить и продолжить». (${(err as Error).message})`
);
}
})();
return id;
};
const handleAddPosition = (): void => {
if (!newPosName.trim()) return;
const id = `extra_pos_${++extraIdCounter}`;
@@ -625,8 +683,10 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
const extraOfferIds: Record<string, number> = {};
for (const es of extraSuppliers) {
if (es.spId) {
// Already created in SP on a previous save — reuse to avoid duplicates.
// Already created in SP — either on a previous save, or eagerly by
// handleAddSupplierFromDatabase (which also sets es.offerId). Reuse to avoid duplicates.
extraSupSpIds[es.id] = es.spId;
if (es.offerId) extraOfferIds[es.id] = es.offerId;
continue;
}
const { spId, offerId } = await service.saveManualSupplierWithKp(es.name);
@@ -825,6 +885,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
handleNameColResizeMouseDown,
handleStatusChange,
handleAddSupplier,
handleAddSupplierFromDatabase,
handleAddPosition,
handleAddDelivery,
handleDeliveryKmChange,
@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect } from 'react';
import { CT, FormStep, Position, Supplier, CurrencyRate, SupplierOffer } from '../types/domain';
import { IMatrixRow } from '../../../../shared/approval';
import { ExtraSupplier } from '../Step3ComparisonTable/matrixTypes';
export interface CTFormData {
ct: CT | undefined;
@@ -15,7 +16,7 @@ export interface CTFormData {
approvalMatrix: IMatrixRow[];
initiatorDepartment: string;
initiatorCompany: string;
step3ExtraSuppliers: Array<{ id: string; name: string; currency: string; spId?: number; status?: string }>;
step3ExtraSuppliers: ExtraSupplier[];
step3ExtraPositions: Array<{ id: string; materialName: string; unit: string; spId?: number; isDelivery?: boolean }>;
step3DeliveryKm?: Record<string, number>; // key = `${posId}::${supId}`
uploadedKpFiles: Array<{ name: string; spId: number; supplierName?: string }>;
@@ -94,6 +94,16 @@ const CHOICE_TO_CT_STATUS: Record<string, CTStatus> = {
Отклонено: "rejected",
};
// Контактные поля поставщика (из «База данных поставщиков») для saveSupplierContacts.
export interface SupplierContactInput {
bin?: string;
address?: string;
contactPerson?: string;
contactInfo?: string;
email?: string;
website?: string;
}
export class CtService {
private readonly baseUrl: string;
private readonly http: SPHttpClient;
@@ -119,6 +129,12 @@ export class CtService {
);
}
// Прокидывает WebPartContext наружу — нужен VendorDatabaseService, который читает список
// на другом сайте (sed) независимо от baseUrl этого сервиса.
get webPartContext(): WebPartContext {
return this.context;
}
private _li(listName: string): string {
return `${this.baseUrl}/_api/web/lists/getByTitle('${encodeURIComponent(listName)}')/items`;
}
@@ -1036,6 +1052,29 @@ export class CtService {
throw new Error(`Ошибка сохранения БИН поставщика: ${res.status}`);
}
// Записывает БИН + контактные данные поставщика (пришедшие из «База данных поставщиков») в
// запись списка Поставщики — единый MERGE, аналог saveSupplierBIN, но для всех полей сразу.
async saveSupplierContacts(
supplierSpId: number,
contacts: SupplierContactInput,
): Promise<void> {
const res = await this.http.post(
this._liById(LISTS.SUPPLIERS, supplierSpId),
SPHttpClient.configurations.v1,
{
method: "POST",
headers: {
"Content-Type": "application/json;odata=nometadata",
"IF-MATCH": "*",
"X-HTTP-Method": "MERGE",
},
body: JSON.stringify(contacts),
},
);
if (!res.ok)
throw new Error(`Ошибка сохранения контактов поставщика: ${res.status}`);
}
// Проверка контрагента в Adata через внутренний прокси (long polling: Get → token → Check → data/wait).
async fetchAdataInfo(bin: string): Promise<AdataCompanyInfo> {
const apiToken = await this._getApiToken();
@@ -0,0 +1,79 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
// Список «База данных поставщиков» живёт на сайте sed (общий справочник, собранный из Excel).
const SED_SITE_URL = 'https://tnsukz.sharepoint.com/sites/sed';
const VENDOR_DB_LIST = 'База данных поставщиков';
export interface IVendorRecord {
id: number;
category: string; // Title
name: string; // SupplierName
bin: string; // SupplierBIN — только цифры
status: string; // SupplierStatus
legalAddress: string;
actualAddress: string;
productName: string;
contactPerson: string;
contactInfo: string;
remark: string;
catalogLink: string;
email: string;
website: string;
sourceSheet: string;
}
interface IVendorRecordRaw {
Id: number;
Title?: string;
SupplierName?: string;
SupplierBIN?: string;
SupplierStatus?: string;
LegalAddress?: string;
ActualAddress?: string;
ProductName?: string;
ContactPerson?: string;
ContactInfo?: string;
Remark?: string;
CatalogLink?: string;
Email?: string;
Website?: string;
SourceSheet?: string;
}
/** Чтение SP-списка «База данных поставщиков» (сайт /sites/sed) — источник для VendorPickerDialog. */
export class VendorDatabaseService {
constructor(private context: WebPartContext) {}
public async listVendors(): Promise<IVendorRecord[]> {
const url =
`${SED_SITE_URL}/_api/web/lists/getbytitle('${encodeURIComponent(VENDOR_DB_LIST)}')/items` +
'?$select=Id,Title,SupplierName,SupplierBIN,SupplierStatus,LegalAddress,ActualAddress,' +
'ProductName,ContactPerson,ContactInfo,Remark,CatalogLink,Email,Website,SourceSheet' +
'&$orderby=SupplierName&$top=5000';
const res: SPHttpClientResponse = await this.context.spHttpClient.get(url, SPHttpClient.configurations.v1);
if (!res.ok) {
throw new Error(`Ошибка загрузки «База данных поставщиков»: ${res.status} ${res.statusText}`);
}
const json: { value: IVendorRecordRaw[] } = await res.json();
return json.value
.map((r) => ({
id: r.Id,
category: (r.Title ?? '').trim(),
name: (r.SupplierName ?? '').trim(),
bin: String(r.SupplierBIN ?? '').replace(/\D/g, ''),
status: (r.SupplierStatus ?? '').trim(),
legalAddress: (r.LegalAddress ?? '').trim(),
actualAddress: (r.ActualAddress ?? '').trim(),
productName: (r.ProductName ?? '').trim(),
contactPerson: (r.ContactPerson ?? '').trim(),
contactInfo: (r.ContactInfo ?? '').trim(),
remark: (r.Remark ?? '').trim(),
catalogLink: (r.CatalogLink ?? '').trim(),
email: (r.Email ?? '').trim(),
website: (r.Website ?? '').trim(),
sourceSheet: (r.SourceSheet ?? '').trim(),
}))
.filter((v) => v.name.length > 0);
}
}