fix(ct): «Дополнительная информация» сохраняется в SP; списки для оплаты, поставки, сертификатов
Ручные правки условий и свои строки («Добавить условие») жили только в браузере: saveCriteria не вызывался, и согласующий, PDF и автор в новой вкладке видели лишь то, что распознал ИИ. Теперь по «Сохранить» шага 3 «Критерии» этой СТ заменяются текущими (replaceCriteria), а при открытии грузятся по СТ, а не по поставщику из всех СТ (loadCriteriaForCt). Свои строки хранятся в новой колонке criteriaLabel, значение — в comment, как в ОИД. По просьбе заказчика: «Условия оплаты» — готовые варианты (частые формулировки из SP) или свой вариант; «Сертификаты» — Да/Нет; «Условия поставки» можно сменить и после заполнения ИИ (раньше заполненное показывалось текстом без выбора).
This commit is contained in:
@@ -255,6 +255,11 @@
|
||||
"type": "Lookup",
|
||||
"lookupList": "Поставщики",
|
||||
"lookupShowField": "name"
|
||||
},
|
||||
{
|
||||
"internalName": "criteriaLabel",
|
||||
"title": "Название доп.условия",
|
||||
"type": "Text"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Step3ComparisonTable.module.scss';
|
||||
|
||||
export interface CriteriaChoiceOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
options: CriteriaChoiceOption[];
|
||||
onChange: (value: string) => void;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
// Ячейка-выбор «Дополнительной информации» (условия поставки, сертификаты). Значение не из списка
|
||||
// (напр. свободный текст от ИИ) показываем отдельным пунктом — чтобы не потерялось и его можно было сменить.
|
||||
export const CriteriaChoiceCell: React.FC<Props> = ({ value, options, onChange, ariaLabel }) => {
|
||||
const current = value.trim();
|
||||
const isKnown = current === '' || options.some(o => o.value === current);
|
||||
return (
|
||||
<select
|
||||
className={styles.criteriaSelect}
|
||||
value={current}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{!isKnown && <option value={current}>{current}</option>}
|
||||
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
+25
-19
@@ -2,13 +2,21 @@ import * as React from 'react';
|
||||
import { Button, IconButton, TextInput, TextArea, Icon } from '@tnsu/ui-kit';
|
||||
import { Step3Matrix } from './useStep3Matrix';
|
||||
import { CriteriaEntry } from './matrixTypes';
|
||||
import { DELIVERY_TYPE_OPTIONS } from './matrixHelpers';
|
||||
import { CERTIFICATE_OPTIONS, DELIVERY_TYPE_OPTIONS } from './matrixHelpers';
|
||||
import { CriteriaChoiceCell, CriteriaChoiceOption } from './CriteriaChoiceCell';
|
||||
import { PaymentTypeCell } from './PaymentTypeCell';
|
||||
import styles from './Step3ComparisonTable.module.scss';
|
||||
|
||||
interface Props {
|
||||
matrix: Step3Matrix;
|
||||
}
|
||||
|
||||
// Поля-выбор: условия поставки (Incoterms — в списке описание, храним код) и сертификаты Да/Нет.
|
||||
const CHOICE_OPTIONS: Partial<Record<keyof CriteriaEntry, CriteriaChoiceOption[]>> = {
|
||||
deliveryType: DELIVERY_TYPE_OPTIONS.map(o => ({ value: o.code, label: o.label })),
|
||||
sertificates: CERTIFICATE_OPTIONS.map(o => ({ value: o, label: o })),
|
||||
};
|
||||
|
||||
// Блок «Дополнительная информация» (условия оплаты/поставки/сроки/сертификаты + произвольные условия).
|
||||
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
|
||||
export function CriteriaTable({ matrix }: Props): JSX.Element | null {
|
||||
@@ -54,24 +62,22 @@ export function CriteriaTable({ matrix }: Props): JSX.Element | null {
|
||||
return (
|
||||
<td key={s.id} className={styles.criteriaValue}>
|
||||
{!isReadOnly ? (
|
||||
row.key === 'deliveryType' ? (
|
||||
(criteriaMap[s.id]?.deliveryType ?? '').trim() ? (
|
||||
// Уже заполнено (в т.ч. ИИ) — показываем как текст, без селекта.
|
||||
criteriaMap[s.id]?.deliveryType
|
||||
) : (
|
||||
// Пусто — даём выбрать условие поставки из списка.
|
||||
<select
|
||||
className={styles.criteriaSelect}
|
||||
value=""
|
||||
onChange={e => updateCriteriaValue(s.id, 'deliveryType', e.target.value)}
|
||||
aria-label={`${row.label} — ${s.name}`}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{DELIVERY_TYPE_OPTIONS.map(o => (
|
||||
<option key={o.code} value={o.code}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
CHOICE_OPTIONS[row.key] ? (
|
||||
// Выбор из списка — всегда, даже если поле уже заполнено (в т.ч. ИИ): раньше
|
||||
// заполненное условие поставки показывалось текстом и его нельзя было сменить.
|
||||
<CriteriaChoiceCell
|
||||
value={criteriaMap[s.id]?.[row.key] ?? ''}
|
||||
options={CHOICE_OPTIONS[row.key] ?? []}
|
||||
onChange={val => updateCriteriaValue(s.id, row.key, val)}
|
||||
ariaLabel={`${row.label} — ${s.name}`}
|
||||
/>
|
||||
) : row.key === 'paymentType' ? (
|
||||
<PaymentTypeCell
|
||||
value={criteriaMap[s.id]?.paymentType ?? ''}
|
||||
onChange={val => updateCriteriaValue(s.id, 'paymentType', val)}
|
||||
ariaLabel={`${row.label} — ${s.name}`}
|
||||
isRequiredEmpty={Boolean(row.required) && isEmpty}
|
||||
/>
|
||||
) : (
|
||||
<div className={[styles.criteriaInput, row.required && isEmpty ? styles.criteriaInputRequired : ''].filter(Boolean).join(' ')}>
|
||||
<TextArea
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { TextArea } from '@tnsu/ui-kit';
|
||||
import { PAYMENT_TYPE_OPTIONS } from './matrixHelpers';
|
||||
import { isPaymentTypeOption } from '../utils/criteria';
|
||||
import styles from './Step3ComparisonTable.module.scss';
|
||||
|
||||
const CUSTOM_OPTION = '__custom';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
ariaLabel: string;
|
||||
isRequiredEmpty: boolean;
|
||||
}
|
||||
|
||||
// Условия оплаты: готовые варианты или «Свой вариант…» с полем ввода. Значение не из списка
|
||||
// (вписанное вручную или распознанное ИИ) открывается сразу как свой вариант.
|
||||
export const PaymentTypeCell: React.FC<Props> = ({ value, onChange, ariaLabel, isRequiredEmpty }) => {
|
||||
// Выбран «Свой вариант…» вручную (поле ещё пустое) — или значение уже не из списка. Второе считаем
|
||||
// от значения, а не один раз при монтировании: условия из SP приходят уже после первого рендера.
|
||||
const [isCustomChosen, setIsCustomChosen] = useState(false);
|
||||
const isCustom = isCustomChosen || (value.trim() !== '' && !isPaymentTypeOption(value));
|
||||
|
||||
const handleSelect = (selected: string): void => {
|
||||
if (selected === CUSTOM_OPTION) {
|
||||
setIsCustomChosen(true);
|
||||
if (isPaymentTypeOption(value)) onChange('');
|
||||
return;
|
||||
}
|
||||
setIsCustomChosen(false);
|
||||
onChange(selected);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={[styles.criteriaInput, isRequiredEmpty ? styles.criteriaInputRequired : ''].filter(Boolean).join(' ')}>
|
||||
<select
|
||||
className={styles.criteriaSelect}
|
||||
value={isCustom ? CUSTOM_OPTION : value.trim()}
|
||||
onChange={e => handleSelect(e.target.value)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{PAYMENT_TYPE_OPTIONS.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
<option value={CUSTOM_OPTION}>Свой вариант…</option>
|
||||
</select>
|
||||
{isCustom && (
|
||||
<TextArea
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Впишите условия оплаты"
|
||||
borderless
|
||||
rows={1}
|
||||
ariaLabel={`${ariaLabel} — свой вариант`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -21,6 +21,19 @@ export const DELIVERY_TYPE_OPTIONS: Array<{ code: string; label: string }> = [
|
||||
{ code: 'DDP', label: 'Поставка до объекта с оплатой всех расходов (DDP) — доставка силами поставщика с включением транспортных и таможенных расходов' },
|
||||
];
|
||||
|
||||
// Условия оплаты — готовые варианты (самые частые формулировки в SP «Критерии»); можно вписать свой.
|
||||
export const PAYMENT_TYPE_OPTIONS: string[] = [
|
||||
'100% предоплата',
|
||||
'50% предоплата, 50% по факту поставки',
|
||||
'30% предоплата, 70% по факту поставки',
|
||||
'40% предоплата, 60% перед отгрузкой',
|
||||
'Оплата по факту поставки',
|
||||
'100% оплата в течение 5 рабочих дней после поставки',
|
||||
];
|
||||
|
||||
// Сертификаты — как Choice-колонка sertificates в SP «Критерии».
|
||||
export const CERTIFICATE_OPTIONS: string[] = ['Да', 'Нет'];
|
||||
|
||||
// Foreign currency = 6 sub-cols (Ед. изм. | Кол-во | Цена_curr | Сумма_curr | Цена ₸ | Сумма ₸)
|
||||
// KZT = 4 sub-cols (Ед. изм. | Кол-во | Цена ₸ | Сумма ₸)
|
||||
// Restricted = 3 sub-cols (Наименование ТМЦ | Ед. изм. | Кол-во)
|
||||
|
||||
+11
-19
@@ -8,6 +8,7 @@ import { applyCreatedIds, buildOfferKey, computeOffer } from './matrixHelpers';
|
||||
import { IVendorRecord } from '../../services/VendorDatabaseService';
|
||||
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
|
||||
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
|
||||
import { buildCriteriaRecords, restoreCriteria } from '../utils/criteria';
|
||||
|
||||
let extraIdCounter = 0;
|
||||
|
||||
@@ -131,27 +132,18 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
const criteriaMapRef = useRef(criteriaMap);
|
||||
const customCriteriaRef = useRef(customCriteria);
|
||||
|
||||
// Load criteria from SP "Критерии" list by supplier SP IDs
|
||||
// «Дополнительная информация» из SP «Критерии» этой СТ: условия поставщиков и свои строки. Правки
|
||||
// из сессии важнее — SP подставляем только туда, где в сессии пусто.
|
||||
React.useEffect(() => {
|
||||
const supplierSpIds = suppliers
|
||||
.filter(s => s.hasOffer && s.spId !== undefined)
|
||||
.map(s => s.spId as number);
|
||||
if (supplierSpIds.length === 0) return;
|
||||
service.loadCriteria(supplierSpIds)
|
||||
.then(rawCriteria => {
|
||||
const spMap: Record<string, CriteriaEntry> = {};
|
||||
// Критерии в SP — на поставщика, а не на КП: у всех колонок поставщика (несколько КП) они общие.
|
||||
rawCriteria.forEach(c => {
|
||||
suppliers
|
||||
.filter(s => s.spId === c.supplierSpId)
|
||||
.forEach(sup => {
|
||||
spMap[sup.id] = { paymentType: c.paymentType, deliveryType: c.deliveryType, deadline: c.deadline, sertificates: c.sertificates };
|
||||
});
|
||||
});
|
||||
// Merge SP data with any user edits from formData — user edits take priority
|
||||
setCriteriaMap(prev => ({ ...spMap, ...prev }));
|
||||
if (!ct?.id) return;
|
||||
const columns = suppliers.filter(s => s.hasOffer !== false);
|
||||
service.loadCriteriaForCt(ct.id)
|
||||
.then(records => {
|
||||
const restored = restoreCriteria(records, columns);
|
||||
setCriteriaMap(prev => ({ ...restored.criteriaMap, ...prev }));
|
||||
setCustomCriteria(prev => (prev.length > 0 ? prev : restored.customCriteria));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
.catch(err => console.warn('[Шаг 3] не удалось загрузить доп. информацию:', err));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { buildCriteriaRecords, isPaymentTypeOption, restoreCriteria } from './criteria';
|
||||
import { Supplier } from '../types/domain';
|
||||
|
||||
const ARLAN_KZT: Supplier = { id: '263_277', spId: 263, kpFileSpId: 277, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true };
|
||||
const ARLAN_USD: Supplier = { id: '263_285', spId: 263, kpFileSpId: 285, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true };
|
||||
const KALA: Supplier = { id: '264_278', spId: 264, kpFileSpId: 278, name: 'ТОО «Кала Саплай»', hasOffer: true };
|
||||
const MANUAL: Supplier = { id: 'extra_sup_1', name: 'Ручной поставщик', hasOffer: true };
|
||||
|
||||
const spIdOf = (column: Supplier): number | undefined => column.spId ?? (column.id === 'extra_sup_1' ? 300 : undefined);
|
||||
|
||||
describe('buildCriteriaRecords (ручные правки «Дополнительной информации» → SP)', () => {
|
||||
// Раньше правки и свои строки жили только в браузере: согласующий и PDF видели лишь то, что распознал ИИ.
|
||||
it('одна запись условий на поставщика', () => {
|
||||
const records = buildCriteriaRecords(
|
||||
[KALA],
|
||||
{ '264_278': { paymentType: '100% предоплата', deliveryType: 'EXW', deadline: '10 дней', sertificates: 'Да' } },
|
||||
[],
|
||||
spIdOf,
|
||||
);
|
||||
|
||||
expect(records).toEqual([{
|
||||
supplierSpId: 264, paymentType: '100% предоплата', deliveryType: 'EXW', deadline: '10 дней', sertificates: 'Да',
|
||||
}]);
|
||||
});
|
||||
|
||||
// Условия в SP — на поставщика, а не на КП: у колонок одного поставщика берём первую заполненную.
|
||||
it('несколько КП одного поставщика — одна запись', () => {
|
||||
const records = buildCriteriaRecords(
|
||||
[ARLAN_KZT, ARLAN_USD],
|
||||
{
|
||||
'263_277': { paymentType: '', deliveryType: '', deadline: '', sertificates: '' },
|
||||
'263_285': { paymentType: '50% предоплата, 50% по факту поставки', deliveryType: '', deadline: '', sertificates: '' },
|
||||
},
|
||||
[],
|
||||
spIdOf,
|
||||
);
|
||||
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]).toMatchObject({ paymentType: '50% предоплата, 50% по факту поставки' });
|
||||
});
|
||||
|
||||
it('свои строки — запись на (строка, поставщик) с непустым значением', () => {
|
||||
const records = buildCriteriaRecords(
|
||||
[KALA, MANUAL],
|
||||
{},
|
||||
[{ id: 'c1', label: 'Гарантия', values: { '264_278': '12 мес', extra_sup_1: '' } }],
|
||||
spIdOf,
|
||||
);
|
||||
|
||||
// Пустые условия поставщика не пишем — только заполненная своя строка.
|
||||
expect(records).toEqual([{ supplierSpId: 264, criteriaLabel: 'Гарантия', value: '12 мес' }]);
|
||||
});
|
||||
|
||||
it('пропускает колонку без SP Id поставщика', () => {
|
||||
const noSp: Supplier = { id: 'local_1', name: 'Без SP', hasOffer: true };
|
||||
|
||||
expect(buildCriteriaRecords([noSp], { local_1: { paymentType: 'x', deliveryType: '', deadline: '', sertificates: '' } }, [], () => undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreCriteria (SP → таблица, в т.ч. по ссылке)', () => {
|
||||
it('условия поставщика расходятся по всем его колонкам, свои строки группируются по названию', () => {
|
||||
const restored = restoreCriteria(
|
||||
[
|
||||
{ id: 1, supplierSpId: 263, paymentType: 'Отсрочка', deliveryType: 'DAP', deadline: '', sertificates: 'Нет' },
|
||||
{ id: 2, supplierSpId: 263, criteriaLabel: 'Гарантия', value: '24 мес' },
|
||||
{ id: 3, supplierSpId: 264, criteriaLabel: 'Гарантия', value: '12 мес' },
|
||||
],
|
||||
[ARLAN_KZT, ARLAN_USD, KALA],
|
||||
);
|
||||
|
||||
expect(restored.criteriaMap['263_277'].paymentType).toBe('Отсрочка');
|
||||
expect(restored.criteriaMap['263_285'].sertificates).toBe('Нет');
|
||||
expect(restored.customCriteria).toEqual([
|
||||
{ id: 'sp_Гарантия', label: 'Гарантия', values: { '263_277': '24 мес', '263_285': '24 мес', '264_278': '12 мес' } },
|
||||
]);
|
||||
});
|
||||
|
||||
// ИИ при каждой обработке КП добавляет запись условий — берём последнюю.
|
||||
it('из нескольких записей условий поставщика берёт последнюю', () => {
|
||||
const restored = restoreCriteria(
|
||||
[
|
||||
{ id: 5, supplierSpId: 264, paymentType: 'старое', deliveryType: '', deadline: '', sertificates: '' },
|
||||
{ id: 9, supplierSpId: 264, paymentType: 'новое', deliveryType: '', deadline: '', sertificates: '' },
|
||||
],
|
||||
[KALA],
|
||||
);
|
||||
|
||||
expect(restored.criteriaMap['264_278'].paymentType).toBe('новое');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPaymentTypeOption', () => {
|
||||
it('значение из списка — да, своё — нет', () => {
|
||||
expect(isPaymentTypeOption('100% предоплата')).toBe(true);
|
||||
expect(isPaymentTypeOption('40% предоплата, 60% по аккредитиву')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Supplier } from '../types/domain';
|
||||
import { CriteriaEntry } from '../Step3ComparisonTable/matrixTypes';
|
||||
import { PAYMENT_TYPE_OPTIONS } from '../Step3ComparisonTable/matrixHelpers';
|
||||
|
||||
// Своя строка «Дополнительной информации» (кнопка «Добавить условие»): значения по колонкам.
|
||||
export interface CustomCriteriaRow {
|
||||
id: string;
|
||||
label: string;
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
// Запись «Критерии» в SP. Условия поставщика — одна запись на поставщика; своя строка — запись на
|
||||
// (строка, поставщик): название в criteriaLabel, значение в comment (как в «Критерии ОИД»).
|
||||
export interface FixedCriteriaRecord {
|
||||
id?: number;
|
||||
supplierSpId: number;
|
||||
paymentType: string;
|
||||
deliveryType: string;
|
||||
deadline: string;
|
||||
sertificates: string;
|
||||
}
|
||||
|
||||
export interface CustomCriteriaRecord {
|
||||
id?: number;
|
||||
supplierSpId: number;
|
||||
criteriaLabel: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type CriteriaRecord = FixedCriteriaRecord | CustomCriteriaRecord;
|
||||
|
||||
const isCustomRecord = (r: CriteriaRecord): r is CustomCriteriaRecord => 'criteriaLabel' in r;
|
||||
|
||||
const EMPTY_ENTRY: CriteriaEntry = { paymentType: '', deliveryType: '', deadline: '', sertificates: '' };
|
||||
|
||||
const hasAnyValue = (e: CriteriaEntry): boolean =>
|
||||
[e.paymentType, e.deliveryType, e.deadline, e.sertificates].some(v => (v ?? '').trim() !== '');
|
||||
|
||||
export function isPaymentTypeOption(value: string): boolean {
|
||||
return PAYMENT_TYPE_OPTIONS.includes(value.trim());
|
||||
}
|
||||
|
||||
// Таблица «Дополнительная информация» → записи SP. Условия в SP — на поставщика, а не на КП: у колонок
|
||||
// одного поставщика (несколько КП) берём первую заполненную. Колонки без SP Id поставщика пропускаем.
|
||||
export function buildCriteriaRecords(
|
||||
columns: Supplier[],
|
||||
criteriaMap: Record<string, CriteriaEntry>,
|
||||
customCriteria: CustomCriteriaRow[],
|
||||
spIdOf: (column: Supplier) => number | undefined,
|
||||
): CriteriaRecord[] {
|
||||
const records: CriteriaRecord[] = [];
|
||||
const savedSuppliers = new Set<number>();
|
||||
|
||||
columns.forEach(column => {
|
||||
const supplierSpId = spIdOf(column);
|
||||
const entry = criteriaMap[column.id];
|
||||
if (supplierSpId === undefined || savedSuppliers.has(supplierSpId) || !entry || !hasAnyValue(entry)) return;
|
||||
savedSuppliers.add(supplierSpId);
|
||||
records.push({
|
||||
supplierSpId,
|
||||
paymentType: entry.paymentType.trim(),
|
||||
deliveryType: entry.deliveryType.trim(),
|
||||
deadline: entry.deadline.trim(),
|
||||
sertificates: entry.sertificates.trim(),
|
||||
});
|
||||
});
|
||||
|
||||
customCriteria.forEach(row => {
|
||||
const savedForRow = new Set<number>();
|
||||
columns.forEach(column => {
|
||||
const supplierSpId = spIdOf(column);
|
||||
const value = (row.values[column.id] ?? '').trim();
|
||||
if (supplierSpId === undefined || !value || savedForRow.has(supplierSpId)) return;
|
||||
savedForRow.add(supplierSpId);
|
||||
records.push({ supplierSpId, criteriaLabel: row.label, value });
|
||||
});
|
||||
});
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
// Записи SP → таблица (в т.ч. при открытии по ссылке). Условия поставщика расходятся по всем его
|
||||
// колонкам; ИИ при каждой обработке КП добавляет запись — берём последнюю (max Id).
|
||||
export function restoreCriteria(
|
||||
records: CriteriaRecord[],
|
||||
columns: Supplier[],
|
||||
): { criteriaMap: Record<string, CriteriaEntry>; customCriteria: CustomCriteriaRow[] } {
|
||||
const latestFixed = new Map<number, FixedCriteriaRecord>();
|
||||
records.forEach(r => {
|
||||
if (isCustomRecord(r)) return;
|
||||
const previous = latestFixed.get(r.supplierSpId);
|
||||
if (!previous || (r.id ?? 0) > (previous.id ?? 0)) latestFixed.set(r.supplierSpId, r);
|
||||
});
|
||||
|
||||
const criteriaMap: Record<string, CriteriaEntry> = {};
|
||||
columns.forEach(column => {
|
||||
const fixed = column.spId !== undefined ? latestFixed.get(column.spId) : undefined;
|
||||
if (!fixed) return;
|
||||
criteriaMap[column.id] = {
|
||||
...EMPTY_ENTRY,
|
||||
paymentType: fixed.paymentType,
|
||||
deliveryType: fixed.deliveryType,
|
||||
deadline: fixed.deadline,
|
||||
sertificates: fixed.sertificates,
|
||||
};
|
||||
});
|
||||
|
||||
const customByLabel = new Map<string, CustomCriteriaRow>();
|
||||
records.forEach(r => {
|
||||
if (!isCustomRecord(r)) return;
|
||||
const row = customByLabel.get(r.criteriaLabel) ?? { id: `sp_${r.criteriaLabel}`, label: r.criteriaLabel, values: {} };
|
||||
columns.filter(c => c.spId === r.supplierSpId).forEach(c => { row.values[c.id] = r.value; });
|
||||
customByLabel.set(r.criteriaLabel, row);
|
||||
});
|
||||
|
||||
return { criteriaMap, customCriteria: Array.from(customByLabel.values()) };
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Request,
|
||||
Supplier,
|
||||
SupplierOffer,
|
||||
SupplierCriteria,
|
||||
SyntexFields,
|
||||
FlowResult,
|
||||
AdataCompanyInfo,
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
keepLatestOfferRecords,
|
||||
parseOfferItemIndex,
|
||||
} from "../../../shared/offers/offerRecords";
|
||||
import { CriteriaRecord } from "../components/utils/criteria";
|
||||
import {
|
||||
NoOfferSupplierEntry,
|
||||
NoOfferSupplierRecord,
|
||||
@@ -613,23 +613,30 @@ export class CtService {
|
||||
|
||||
// ─── Критерии ──────────────────────────────────────────────────────────────
|
||||
|
||||
async saveCriteria(
|
||||
criteria: SupplierCriteria[],
|
||||
ctSpId?: number,
|
||||
): Promise<void> {
|
||||
if (criteria.length === 0) return;
|
||||
// Было: POST на каждый критерий в цикле await. Стало: один $batch (у списка «Критерии»
|
||||
// капризного вебхука нет, changeset безопасен).
|
||||
const bodies = criteria.map((c) => ({
|
||||
Title: c.supplierId,
|
||||
supplierId: parseInt(c.supplierId, 10),
|
||||
deliveryType: c.deliveryType,
|
||||
paymentType: c.paymentType,
|
||||
deadline: c.deadline,
|
||||
sertificates: c.certificates,
|
||||
comment: c.comment ?? "",
|
||||
...(ctSpId ? { CTId: ctSpId } : {}),
|
||||
}));
|
||||
// «Дополнительная информация» шага 3 → «Критерии» этой СТ целиком: прежние записи СТ (в т.ч. от ИИ)
|
||||
// в корзину, текущие — заново. Раньше ручные правки и свои строки в SP не попадали вовсе.
|
||||
async replaceCriteria(ctId: string, records: CriteriaRecord[]): Promise<void> {
|
||||
const ctSpId = parseInt(ctId, 10);
|
||||
const existing = await this.loadCriteriaForCt(ctId);
|
||||
await this._runChunked(existing.map((r) => r.id as number), 20, async (id) => {
|
||||
await this.http
|
||||
.post(`${this._liById(LISTS.CRITERIA, id)}/recycle()`, SPHttpClient.configurations.v1, {})
|
||||
.catch(() => undefined /* уже в корзине / не найден — игнорируем */);
|
||||
});
|
||||
// У списка «Критерии» капризного вебхука нет — пакетное создание безопасно.
|
||||
const bodies = records.map((r) =>
|
||||
"criteriaLabel" in r
|
||||
? { Title: `${ctSpId}-${r.supplierSpId}-${r.criteriaLabel}`, CTId: ctSpId, supplierId: r.supplierSpId, criteriaLabel: r.criteriaLabel, comment: r.value }
|
||||
: {
|
||||
Title: String(r.supplierSpId),
|
||||
CTId: ctSpId,
|
||||
supplierId: r.supplierSpId,
|
||||
paymentType: r.paymentType,
|
||||
deliveryType: r.deliveryType,
|
||||
deadline: r.deadline,
|
||||
sertificates: r.sertificates,
|
||||
},
|
||||
);
|
||||
await this.batchCreateItems(LISTS.CRITERIA, bodies);
|
||||
}
|
||||
|
||||
@@ -661,30 +668,27 @@ export class CtService {
|
||||
if (!res.ok) throw new Error(`Ошибка сохранения критериев: ${res.status}`);
|
||||
}
|
||||
|
||||
async loadCriteria(supplierSpIds: number[]): Promise<
|
||||
Array<{
|
||||
supplierSpId: number;
|
||||
paymentType: string;
|
||||
deliveryType: string;
|
||||
deadline: string;
|
||||
sertificates: string;
|
||||
}>
|
||||
> {
|
||||
if (supplierSpIds.length === 0) return [];
|
||||
const filter = supplierSpIds
|
||||
.map((id) => `supplierId eq ${id}`)
|
||||
.join(" or ");
|
||||
const url = `${this._li(LISTS.CRITERIA)}?$select=supplierId,paymentType,deliveryType,deadline,sertificates&$filter=${filter}`;
|
||||
// «Критерии» этой СТ. Раньше читались по поставщику без фильтра по СТ — подтягивались условия
|
||||
// того же поставщика из других СТ.
|
||||
async loadCriteriaForCt(ctId: string): Promise<CriteriaRecord[]> {
|
||||
const url =
|
||||
`${this._li(LISTS.CRITERIA)}?$select=Id,supplierId,paymentType,deliveryType,deadline,sertificates,criteriaLabel,comment` +
|
||||
`&$filter=CTId eq ${parseInt(ctId, 10)}&$top=2000`;
|
||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||
if (!res.ok) return [];
|
||||
if (!res.ok) throw new Error(`Ошибка чтения критериев: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.value ?? []).map((item: Record<string, unknown>) => ({
|
||||
supplierSpId: Number(item.supplierId),
|
||||
paymentType: String(item.paymentType ?? ""),
|
||||
deliveryType: String(item.deliveryType ?? ""),
|
||||
deadline: String(item.deadline ?? ""),
|
||||
sertificates: String(item.sertificates ?? ""),
|
||||
}));
|
||||
return ((data.value ?? []) as Array<Record<string, unknown>>).map((item): CriteriaRecord => {
|
||||
const base = { id: Number(item.Id), supplierSpId: Number(item.supplierId) };
|
||||
const label = String(item.criteriaLabel ?? "").trim();
|
||||
if (label) return { ...base, criteriaLabel: label, value: String(item.comment ?? "") };
|
||||
return {
|
||||
...base,
|
||||
paymentType: String(item.paymentType ?? ""),
|
||||
deliveryType: String(item.deliveryType ?? ""),
|
||||
deadline: String(item.deadline ?? ""),
|
||||
sertificates: String(item.sertificates ?? ""),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Согласование СТ (через shared/approval — approval-route API + список
|
||||
|
||||
Reference in New Issue
Block a user