feat(ct): выбор поставщика из базы данных на шаге СТ
This commit is contained in:
+2
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-1
@@ -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
|
||||
|
||||
+105
@@ -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;
|
||||
}
|
||||
+176
@@ -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 {
|
||||
|
||||
+9
-1
@@ -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 };
|
||||
}
|
||||
|
||||
+63
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user