fix(ct): «Дополнительная информация» сохраняется в SP; списки для оплаты, поставки, сертификатов
Ручные правки условий и свои строки («Добавить условие») жили только в браузере: saveCriteria не вызывался, и согласующий, PDF и автор в новой вкладке видели лишь то, что распознал ИИ. Теперь по «Сохранить» шага 3 «Критерии» этой СТ заменяются текущими (replaceCriteria), а при открытии грузятся по СТ, а не по поставщику из всех СТ (loadCriteriaForCt). Свои строки хранятся в новой колонке criteriaLabel, значение — в comment, как в ОИД. По просьбе заказчика: «Условия оплаты» — готовые варианты (частые формулировки из SP) или свой вариант; «Сертификаты» — Да/Нет; «Условия поставки» можно сменить и после заполнения ИИ (раньше заполненное показывалось текстом без выбора).
This commit is contained in:
@@ -255,6 +255,11 @@
|
|||||||
"type": "Lookup",
|
"type": "Lookup",
|
||||||
"lookupList": "Поставщики",
|
"lookupList": "Поставщики",
|
||||||
"lookupShowField": "name"
|
"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 { Button, IconButton, TextInput, TextArea, Icon } from '@tnsu/ui-kit';
|
||||||
import { Step3Matrix } from './useStep3Matrix';
|
import { Step3Matrix } from './useStep3Matrix';
|
||||||
import { CriteriaEntry } from './matrixTypes';
|
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';
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
matrix: Step3Matrix;
|
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).
|
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
|
||||||
export function CriteriaTable({ matrix }: Props): JSX.Element | null {
|
export function CriteriaTable({ matrix }: Props): JSX.Element | null {
|
||||||
@@ -54,24 +62,22 @@ export function CriteriaTable({ matrix }: Props): JSX.Element | null {
|
|||||||
return (
|
return (
|
||||||
<td key={s.id} className={styles.criteriaValue}>
|
<td key={s.id} className={styles.criteriaValue}>
|
||||||
{!isReadOnly ? (
|
{!isReadOnly ? (
|
||||||
row.key === 'deliveryType' ? (
|
CHOICE_OPTIONS[row.key] ? (
|
||||||
(criteriaMap[s.id]?.deliveryType ?? '').trim() ? (
|
// Выбор из списка — всегда, даже если поле уже заполнено (в т.ч. ИИ): раньше
|
||||||
// Уже заполнено (в т.ч. ИИ) — показываем как текст, без селекта.
|
// заполненное условие поставки показывалось текстом и его нельзя было сменить.
|
||||||
criteriaMap[s.id]?.deliveryType
|
<CriteriaChoiceCell
|
||||||
) : (
|
value={criteriaMap[s.id]?.[row.key] ?? ''}
|
||||||
// Пусто — даём выбрать условие поставки из списка.
|
options={CHOICE_OPTIONS[row.key] ?? []}
|
||||||
<select
|
onChange={val => updateCriteriaValue(s.id, row.key, val)}
|
||||||
className={styles.criteriaSelect}
|
ariaLabel={`${row.label} — ${s.name}`}
|
||||||
value=""
|
/>
|
||||||
onChange={e => updateCriteriaValue(s.id, 'deliveryType', e.target.value)}
|
) : row.key === 'paymentType' ? (
|
||||||
aria-label={`${row.label} — ${s.name}`}
|
<PaymentTypeCell
|
||||||
>
|
value={criteriaMap[s.id]?.paymentType ?? ''}
|
||||||
<option value="">—</option>
|
onChange={val => updateCriteriaValue(s.id, 'paymentType', val)}
|
||||||
{DELIVERY_TYPE_OPTIONS.map(o => (
|
ariaLabel={`${row.label} — ${s.name}`}
|
||||||
<option key={o.code} value={o.code}>{o.label}</option>
|
isRequiredEmpty={Boolean(row.required) && isEmpty}
|
||||||
))}
|
/>
|
||||||
</select>
|
|
||||||
)
|
|
||||||
) : (
|
) : (
|
||||||
<div className={[styles.criteriaInput, row.required && isEmpty ? styles.criteriaInputRequired : ''].filter(Boolean).join(' ')}>
|
<div className={[styles.criteriaInput, row.required && isEmpty ? styles.criteriaInputRequired : ''].filter(Boolean).join(' ')}>
|
||||||
<TextArea
|
<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) — доставка силами поставщика с включением транспортных и таможенных расходов' },
|
{ 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 | Цена ₸ | Сумма ₸)
|
// Foreign currency = 6 sub-cols (Ед. изм. | Кол-во | Цена_curr | Сумма_curr | Цена ₸ | Сумма ₸)
|
||||||
// KZT = 4 sub-cols (Ед. изм. | Кол-во | Цена ₸ | Сумма ₸)
|
// KZT = 4 sub-cols (Ед. изм. | Кол-во | Цена ₸ | Сумма ₸)
|
||||||
// Restricted = 3 sub-cols (Наименование ТМЦ | Ед. изм. | Кол-во)
|
// Restricted = 3 sub-cols (Наименование ТМЦ | Ед. изм. | Кол-во)
|
||||||
|
|||||||
+11
-19
@@ -8,6 +8,7 @@ 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';
|
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
|
||||||
|
import { buildCriteriaRecords, restoreCriteria } from '../utils/criteria';
|
||||||
|
|
||||||
let extraIdCounter = 0;
|
let extraIdCounter = 0;
|
||||||
|
|
||||||
@@ -131,27 +132,18 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
const criteriaMapRef = useRef(criteriaMap);
|
const criteriaMapRef = useRef(criteriaMap);
|
||||||
const customCriteriaRef = useRef(customCriteria);
|
const customCriteriaRef = useRef(customCriteria);
|
||||||
|
|
||||||
// Load criteria from SP "Критерии" list by supplier SP IDs
|
// «Дополнительная информация» из SP «Критерии» этой СТ: условия поставщиков и свои строки. Правки
|
||||||
|
// из сессии важнее — SP подставляем только туда, где в сессии пусто.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const supplierSpIds = suppliers
|
if (!ct?.id) return;
|
||||||
.filter(s => s.hasOffer && s.spId !== undefined)
|
const columns = suppliers.filter(s => s.hasOffer !== false);
|
||||||
.map(s => s.spId as number);
|
service.loadCriteriaForCt(ct.id)
|
||||||
if (supplierSpIds.length === 0) return;
|
.then(records => {
|
||||||
service.loadCriteria(supplierSpIds)
|
const restored = restoreCriteria(records, columns);
|
||||||
.then(rawCriteria => {
|
setCriteriaMap(prev => ({ ...restored.criteriaMap, ...prev }));
|
||||||
const spMap: Record<string, CriteriaEntry> = {};
|
setCustomCriteria(prev => (prev.length > 0 ? prev : restored.customCriteria));
|
||||||
// Критерии в 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 }));
|
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(err => console.warn('[Шаг 3] не удалось загрузить доп. информацию:', err));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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,
|
Request,
|
||||||
Supplier,
|
Supplier,
|
||||||
SupplierOffer,
|
SupplierOffer,
|
||||||
SupplierCriteria,
|
|
||||||
SyntexFields,
|
SyntexFields,
|
||||||
FlowResult,
|
FlowResult,
|
||||||
AdataCompanyInfo,
|
AdataCompanyInfo,
|
||||||
@@ -30,6 +29,7 @@ import {
|
|||||||
keepLatestOfferRecords,
|
keepLatestOfferRecords,
|
||||||
parseOfferItemIndex,
|
parseOfferItemIndex,
|
||||||
} from "../../../shared/offers/offerRecords";
|
} from "../../../shared/offers/offerRecords";
|
||||||
|
import { CriteriaRecord } from "../components/utils/criteria";
|
||||||
import {
|
import {
|
||||||
NoOfferSupplierEntry,
|
NoOfferSupplierEntry,
|
||||||
NoOfferSupplierRecord,
|
NoOfferSupplierRecord,
|
||||||
@@ -613,23 +613,30 @@ export class CtService {
|
|||||||
|
|
||||||
// ─── Критерии ──────────────────────────────────────────────────────────────
|
// ─── Критерии ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async saveCriteria(
|
// «Дополнительная информация» шага 3 → «Критерии» этой СТ целиком: прежние записи СТ (в т.ч. от ИИ)
|
||||||
criteria: SupplierCriteria[],
|
// в корзину, текущие — заново. Раньше ручные правки и свои строки в SP не попадали вовсе.
|
||||||
ctSpId?: number,
|
async replaceCriteria(ctId: string, records: CriteriaRecord[]): Promise<void> {
|
||||||
): Promise<void> {
|
const ctSpId = parseInt(ctId, 10);
|
||||||
if (criteria.length === 0) return;
|
const existing = await this.loadCriteriaForCt(ctId);
|
||||||
// Было: POST на каждый критерий в цикле await. Стало: один $batch (у списка «Критерии»
|
await this._runChunked(existing.map((r) => r.id as number), 20, async (id) => {
|
||||||
// капризного вебхука нет, changeset безопасен).
|
await this.http
|
||||||
const bodies = criteria.map((c) => ({
|
.post(`${this._liById(LISTS.CRITERIA, id)}/recycle()`, SPHttpClient.configurations.v1, {})
|
||||||
Title: c.supplierId,
|
.catch(() => undefined /* уже в корзине / не найден — игнорируем */);
|
||||||
supplierId: parseInt(c.supplierId, 10),
|
});
|
||||||
deliveryType: c.deliveryType,
|
// У списка «Критерии» капризного вебхука нет — пакетное создание безопасно.
|
||||||
paymentType: c.paymentType,
|
const bodies = records.map((r) =>
|
||||||
deadline: c.deadline,
|
"criteriaLabel" in r
|
||||||
sertificates: c.certificates,
|
? { Title: `${ctSpId}-${r.supplierSpId}-${r.criteriaLabel}`, CTId: ctSpId, supplierId: r.supplierSpId, criteriaLabel: r.criteriaLabel, comment: r.value }
|
||||||
comment: c.comment ?? "",
|
: {
|
||||||
...(ctSpId ? { CTId: ctSpId } : {}),
|
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);
|
await this.batchCreateItems(LISTS.CRITERIA, bodies);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,30 +668,27 @@ export class CtService {
|
|||||||
if (!res.ok) throw new Error(`Ошибка сохранения критериев: ${res.status}`);
|
if (!res.ok) throw new Error(`Ошибка сохранения критериев: ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadCriteria(supplierSpIds: number[]): Promise<
|
// «Критерии» этой СТ. Раньше читались по поставщику без фильтра по СТ — подтягивались условия
|
||||||
Array<{
|
// того же поставщика из других СТ.
|
||||||
supplierSpId: number;
|
async loadCriteriaForCt(ctId: string): Promise<CriteriaRecord[]> {
|
||||||
paymentType: string;
|
const url =
|
||||||
deliveryType: string;
|
`${this._li(LISTS.CRITERIA)}?$select=Id,supplierId,paymentType,deliveryType,deadline,sertificates,criteriaLabel,comment` +
|
||||||
deadline: string;
|
`&$filter=CTId eq ${parseInt(ctId, 10)}&$top=2000`;
|
||||||
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}`;
|
|
||||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
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();
|
const data = await res.json();
|
||||||
return (data.value ?? []).map((item: Record<string, unknown>) => ({
|
return ((data.value ?? []) as Array<Record<string, unknown>>).map((item): CriteriaRecord => {
|
||||||
supplierSpId: Number(item.supplierId),
|
const base = { id: Number(item.Id), supplierSpId: Number(item.supplierId) };
|
||||||
paymentType: String(item.paymentType ?? ""),
|
const label = String(item.criteriaLabel ?? "").trim();
|
||||||
deliveryType: String(item.deliveryType ?? ""),
|
if (label) return { ...base, criteriaLabel: label, value: String(item.comment ?? "") };
|
||||||
deadline: String(item.deadline ?? ""),
|
return {
|
||||||
sertificates: String(item.sertificates ?? ""),
|
...base,
|
||||||
}));
|
paymentType: String(item.paymentType ?? ""),
|
||||||
|
deliveryType: String(item.deliveryType ?? ""),
|
||||||
|
deadline: String(item.deadline ?? ""),
|
||||||
|
sertificates: String(item.sertificates ?? ""),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Согласование СТ (через shared/approval — approval-route API + список
|
// ─── Согласование СТ (через shared/approval — approval-route API + список
|
||||||
|
|||||||
Reference in New Issue
Block a user