diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/KpReplaceDialog.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/KpReplaceDialog.tsx deleted file mode 100644 index 704c702..0000000 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/KpReplaceDialog.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from 'react'; -import { Button, Dialog } from '@tnsu/ui-kit'; -import { KpReplaceChoice } from './kpReplace'; -import { KpReplaceQuestion } from './useKpReplaceQuestion'; -import styles from './Step2CommercialOffers.module.scss'; - -interface Props { - question: KpReplaceQuestion; - onAnswer: (choice: KpReplaceChoice) => void; -} - -// Окно выбора для второго КП того же поставщика. Закрытие крестиком/Esc = «Добавить отдельно»: -// безопасный вариант, ничего не удаляет. -export const KpReplaceDialog: React.FC = ({ question, onAnswer }) => ( - onAnswer('separate')} - width="md" - footer={ - <> - - - - } - > -

Уже загружено:

- -

- «Заменить» — прежний КП уберётся из сравнения, останется новый (исправленный КП, тот же файл повторно). - «Добавить отдельным предложением» — оба КП будут отдельными колонками (напр. другая валюта или вариант). -

-
-); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.module.scss b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.module.scss index 857a978..56dc2f6 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.module.scss +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.module.scss @@ -207,15 +207,20 @@ gap: 12px; } -// Окно «У поставщика уже есть КП» (KpReplaceDialog). -.replaceExistingList { - margin: 4px 0 12px; - padding-left: 20px; - font-weight: 600; +// «Загруженные КП»: файлы одного поставщика сгруппированы, у нескольких КП — пометка. +.kpGroup { + display: flex; + flex-direction: column; + gap: 4px; } -.replaceHint { - margin: 0; - font-size: 13px; +.kpGroupHeader { + display: flex; + align-items: center; + gap: 8px; +} + +.kpGroupNote { + font-size: 12px; color: $muted; } diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.tsx index b2e74c5..794a601 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { useState } from 'react'; -import { Button, Alert, Spinner } from '@tnsu/ui-kit'; +import { Button, Alert, Spinner, confirmModal } from '@tnsu/ui-kit'; import { CurrencyRate, FormStep, Supplier } from '../types/domain'; import { CtService } from '../../services/CtService'; import { CTFormData } from '../hooks/useCtForm'; @@ -11,7 +11,7 @@ import { useKpProcessing } from './useKpProcessing'; import { persistStep2 } from './step2Persist'; import { SuppliersWithoutKp } from './SuppliersWithoutKp'; import { UploadedKpList } from './UploadedKpList'; -import { KpReplaceDialog } from './KpReplaceDialog'; +import { removeKpFileFromComparison } from './kpRemoval'; import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency'; import styles from './Step2CommercialOffers.module.scss'; @@ -92,6 +92,48 @@ export const Step2CommercialOffers: React.FC = ({ .forEach(f => kp.handleCurrencyChange(f.localId, currencyCode)); }; + // Убрать КП из сравнения (корзина в «Загруженные КП» или крестик у обработанного файла): так + // заменяют исправленный КП — загружают новый, старый убирают. Колонка и цены уходят сразу, + // записи этого файла в SP — в корзину. Сам файл остаётся в библиотеке. + const handleRemoveKpFile = async (fileSpId: number): Promise => { + const file = formData.uploadedKpFiles.find(u => u.spId === fileSpId); + if (!file) { + kp.removeFileBySpId(fileSpId); + return; + } + const confirmed = await confirmModal({ + title: 'Убрать КП из сравнения?', + message: `${file.name}${file.supplierName ? ` (${file.supplierName})` : ''} — его колонка и цены уйдут из таблицы сравнения. Файл останется в библиотеке КП.`, + confirmText: 'Убрать', + danger: true, + }); + if (!confirmed) return; + + kp.removeFileBySpId(fileSpId); + onSave(removeKpFileFromComparison( + { suppliers: formData.suppliers ?? [], offers: formData.offers ?? [], uploadedKpFiles: formData.uploadedKpFiles }, + file, + )); + const positionSpIds = [...formData.selectedPositions, ...(formData.step3ExtraPositions ?? [])] + .map(p => p.spId) + .filter((id): id is number => id !== undefined); + service.recycleKpFileOffers(fileSpId, positionSpIds).catch(err => { + // Не критично: «Сохранить» на Шаге 2/3 перезапишет предложения позиций по текущей таблице. + console.warn('[КП] не удалось убрать предложения КП из SP:', err); + }); + }; + + // Крестик в очереди загрузки: обработанный КП уже в сравнении — убираем его так же, как корзиной; + // файл в процессе или с ошибкой — просто убираем из очереди. + const handleQueueFileRemove = (localId: string): void => { + const file = kp.kpFiles.find(f => f.localId === localId); + if (file?.status === 'done' && file.spId !== undefined) { + handleRemoveKpFile(file.spId).catch(() => undefined); + return; + } + kp.handleFileRemove(localId); + }; + const handleSave = async (): Promise => { setIsSaving(true); setSaveError(undefined); @@ -130,15 +172,12 @@ export const Step2CommercialOffers: React.FC = ({ { kp.handleFilesAdded(files).catch(() => undefined); }} - onFileRemove={kp.handleFileRemove} + onFileRemove={handleQueueFileRemove} currencyOptions={currencyOptions} onCurrencyChange={kp.handleCurrencyChange} disabled={isNotificationLink || isReadOnly} /> {kp.duplicateWarning && {kp.duplicateWarning}} - {kp.replaceQuestion && ( - - )} {kp.isProcessing && ( Файлы обрабатываются системой Syntex. Можно продолжить заполнение формы. )} @@ -159,7 +198,7 @@ export const Step2CommercialOffers: React.FC = ({ currencyOptions={currencyOptions} getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)} onCurrencyChange={handleUploadedCurrencyChange} - onRemove={(spId) => onSave({ uploadedKpFiles: formData.uploadedKpFiles.filter(u => u.spId !== spId) })} + onRemove={spId => { handleRemoveKpFile(spId).catch(() => undefined); }} disabled={isNotificationLink || isReadOnly} /> )} diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/UploadedKpList.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/UploadedKpList.tsx index 536bbb7..e02ce48 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/UploadedKpList.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/UploadedKpList.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; -import { IconButton } from '@tnsu/ui-kit'; import { CtService } from '../../services/CtService'; import { CTFormData } from '../hooks/useCtForm'; import { KpFileRef } from './kpCurrency'; +import { groupUploadedKpFiles } from './kpRemoval'; +import { UploadedKpRow } from './UploadedKpRow'; import styles from './Step2CommercialOffers.module.scss'; interface Props { @@ -15,54 +16,33 @@ interface Props { disabled?: boolean; } -// Секция «Загруженные КП» (постоянный список из formData): ссылка на файл, валюта КП и удаление. -// Валюту здесь можно поправить и после возврата с Шага 3 — очередь загрузки к этому моменту уже пуста. -export const UploadedKpList: React.FC = ({ - files, - service, - currencyOptions, - getCurrency, - onCurrencyChange, - onRemove, - disabled, -}) => ( +// Секция «Загруженные КП» (постоянный список из formData), сгруппированная по поставщику. Каждый КП — +// отдельная колонка сравнения; несколько КП одного поставщика стоят рядом с пометкой, лишний +// (напр. старая версия КП) убирают корзиной. Валюту можно поправить и после возврата с Шага 3. +export const UploadedKpList: React.FC = ({ files, ...rowProps }) => (

Загруженные КП

    - {files.map(f => ( -
  • - {f.supplierName && {f.supplierName}} - - {f.name} - - {disabled ? ( - {getCurrency(f)} - ) : ( - <> - - onRemove(f.spId)} - /> - - )} -
  • - ))} + {groupUploadedKpFiles(files).map(group => { + const isMultiple = group.files.length > 1; + return ( +
  • + {isMultiple && ( +
    + {group.supplierName} + + {group.files.length} КП — сравниваются отдельными колонками; лишний уберите корзиной + +
    + )} +
      + {group.files.map(file => ( + + ))} +
    +
  • + ); + })}
); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/UploadedKpRow.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/UploadedKpRow.tsx new file mode 100644 index 0000000..d58e99a --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/UploadedKpRow.tsx @@ -0,0 +1,63 @@ +import * as React from 'react'; +import { IconButton } from '@tnsu/ui-kit'; +import { CtService } from '../../services/CtService'; +import { CTFormData } from '../hooks/useCtForm'; +import { KpFileRef } from './kpCurrency'; +import styles from './Step2CommercialOffers.module.scss'; + +interface Props { + file: CTFormData['uploadedKpFiles'][number]; + // Название поставщика в строке — когда у него один КП (в группе из нескольких оно в заголовке группы). + showSupplierName: boolean; + service: CtService; + currencyOptions: string[]; + getCurrency: (file: KpFileRef) => string; + onCurrencyChange: (spId: number, currencyCode: string) => void; + onRemove: (spId: number) => void; + disabled?: boolean; +} + +// Строка «Загруженные КП»: ссылка на файл, валюта КП и «убрать из сравнения». +export const UploadedKpRow: React.FC = ({ + file, + showSupplierName, + service, + currencyOptions, + getCurrency, + onCurrencyChange, + onRemove, + disabled, +}) => ( +
  • + {showSupplierName && file.supplierName && {file.supplierName}} + + {file.name} + + {disabled ? ( + {getCurrency(file)} + ) : ( + <> + + onRemove(file.spId)} + /> + + )} +
  • +); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/kpRemoval.test.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/kpRemoval.test.ts new file mode 100644 index 0000000..b520660 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/kpRemoval.test.ts @@ -0,0 +1,79 @@ +import { groupUploadedKpFiles, removeKpFileFromComparison } from './kpRemoval'; +import { Supplier, SupplierOffer } from '../types/domain'; + +const ARLAN = 'ТОО «Арлан Офис Снаб»'; +const KALA = 'ТОО «Кала Саплай»'; +const ARLAN_KZT: Supplier = { id: '263_277', spId: 263, kpFileSpId: 277, name: ARLAN, hasOffer: true }; +const ARLAN_USD: Supplier = { id: '263_285', spId: 263, kpFileSpId: 285, name: ARLAN, hasOffer: true }; +const KALA_KZT: Supplier = { id: '264_278', spId: 264, kpFileSpId: 278, name: KALA, hasOffer: true }; +const UPLOADED = [ + { spId: 277, name: 'KP_01_Arlan_Office_KZT.pdf', supplierName: ARLAN }, + { spId: 278, name: 'KP_02_Qala_Supply_KZT.pdf', supplierName: KALA }, + { spId: 285, name: 'KP_01_Arlan_Office_USD.pdf', supplierName: ARLAN }, +]; + +function makeOffer(supplierId: string): SupplierOffer { + return { + id: '', supplierId, positionId: 'p1', pricePerUnit: 1, quantity: 1, currencyCode: 'KZT', + pricePerUnitKzt: 1, totalPriceKzt: 1, totalWithExtraKzt: 1, + }; +} + +describe('removeKpFileFromComparison', () => { + // Загрузили исправленный КП Арлан — старый убирают корзиной: уходит только его колонка. + it('убирает колонку, предложения и строку списка только этого КП', () => { + const result = removeKpFileFromComparison( + { + suppliers: [ARLAN_KZT, KALA_KZT, ARLAN_USD], + offers: [makeOffer('263_277'), makeOffer('264_278'), makeOffer('263_285')], + uploadedKpFiles: UPLOADED, + }, + UPLOADED[0], + ); + + expect(result.suppliers.map(s => s.id)).toEqual(['264_278', '263_285']); + expect(result.offers.map(o => o.supplierId)).toEqual(['264_278', '263_285']); + expect(result.uploadedKpFiles.map(f => f.spId)).toEqual([278, 285]); + }); + + it('старую колонку без файла убирает, когда это последний КП поставщика', () => { + const legacy: Supplier = { id: '264', spId: 264, name: KALA, hasOffer: true }; + + const result = removeKpFileFromComparison( + { suppliers: [legacy], offers: [makeOffer('264')], uploadedKpFiles: [UPLOADED[1]] }, + UPLOADED[1], + ); + + expect(result.suppliers).toEqual([]); + expect(result.offers).toEqual([]); + }); + + it('старую колонку без файла оставляет, пока у поставщика есть другие КП', () => { + const legacy: Supplier = { id: '263', spId: 263, name: ARLAN, hasOffer: true }; + + const result = removeKpFileFromComparison( + { suppliers: [legacy], offers: [], uploadedKpFiles: [UPLOADED[0], UPLOADED[2]] }, + UPLOADED[0], + ); + + expect(result.suppliers).toEqual([legacy]); + }); +}); + +describe('groupUploadedKpFiles', () => { + it('группирует КП по поставщику в порядке появления', () => { + const groups = groupUploadedKpFiles(UPLOADED); + + expect(groups.map(g => ({ supplierName: g.supplierName, spIds: g.files.map(f => f.spId) }))).toEqual([ + { supplierName: ARLAN, spIds: [277, 285] }, + { supplierName: KALA, spIds: [278] }, + ]); + }); + + it('файл без поставщика — отдельной группой', () => { + const groups = groupUploadedKpFiles([{ spId: 1, name: 'a.pdf' }, { spId: 2, name: 'b.pdf' }]); + + expect(groups).toHaveLength(2); + expect(groups[0].supplierName).toBeUndefined(); + }); +}); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/kpRemoval.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/kpRemoval.ts new file mode 100644 index 0000000..84499aa --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/kpRemoval.ts @@ -0,0 +1,47 @@ +import { CTFormData } from '../hooks/useCtForm'; + +type UploadedKpFile = CTFormData['uploadedKpFiles'][number]; +type ComparisonData = Pick; + +// Убрать КП из сравнения (корзина в «Загруженные КП»): его колонку, предложения и строку списка. +// Так заменяют исправленный КП — загружают новый, старый убирают. Сам файл остаётся в библиотеке. +// Старая колонка без файла (kpFileSpId) общая на все КП поставщика — убираем её, только когда +// у поставщика не остаётся других КП. +export function removeKpFileFromComparison(data: ComparisonData, file: UploadedKpFile): ComparisonData { + const remainingFiles = data.uploadedKpFiles.filter(f => f.spId !== file.spId); + const hasFileColumn = data.suppliers.some(s => s.kpFileSpId === file.spId); + const supplierHasOtherFiles = remainingFiles.some(f => f.supplierName === file.supplierName); + + const isRemoved = (s: CTFormData['suppliers'][number]): boolean => { + if (!s.hasOffer) return false; + if (hasFileColumn) return s.kpFileSpId === file.spId; + return s.kpFileSpId === undefined && s.name === file.supplierName && !supplierHasOtherFiles; + }; + + const removedColumnIds = new Set(data.suppliers.filter(isRemoved).map(s => s.id)); + return { + suppliers: data.suppliers.filter(s => !removedColumnIds.has(s.id)), + offers: data.offers.filter(o => !removedColumnIds.has(o.supplierId)), + uploadedKpFiles: remainingFiles, + }; +} + +export interface UploadedKpGroup { + supplierName?: string; + files: UploadedKpFile[]; +} + +// Группы «поставщик → его КП» для списка «Загруженные КП», в порядке первого появления. Несколько КП +// одного поставщика стоят рядом — видно, что они сравниваются отдельными колонками. +export function groupUploadedKpFiles(files: UploadedKpFile[]): UploadedKpGroup[] { + const groups: UploadedKpGroup[] = []; + files.forEach(file => { + const group = file.supplierName ? groups.find(g => g.supplierName === file.supplierName) : undefined; + if (group) { + group.files.push(file); + return; + } + groups.push({ supplierName: file.supplierName, files: [file] }); + }); + return groups; +} diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/kpReplace.test.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/kpReplace.test.ts deleted file mode 100644 index 60da763..0000000 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/kpReplace.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { findOtherKpFilesOfSupplier, removeReplacedColumns } from './kpReplace'; -import { Supplier, SupplierOffer } from '../types/domain'; - -const ARLAN = 'ТОО «Арлан Офис Снаб»'; -const KZT_COLUMN: Supplier = { id: '263_277', spId: 263, kpFileSpId: 277, name: ARLAN, hasOffer: true }; -const KALA_COLUMN: Supplier = { id: '264_278', spId: 264, kpFileSpId: 278, name: 'ТОО «Кала Саплай»', hasOffer: true }; -const UPLOADED = [ - { spId: 277, name: 'KP_01_Arlan_Office_KZT.pdf', supplierName: ARLAN }, - { spId: 278, name: 'KP_02_Qala_Supply_KZT.pdf', supplierName: 'ТОО «Кала Саплай»' }, -]; - -function makeOffer(supplierId: string): SupplierOffer { - return { - id: '', supplierId, positionId: 'p1', pricePerUnit: 1, quantity: 1, currencyCode: 'KZT', - pricePerUnitKzt: 1, totalPriceKzt: 1, totalWithExtraKzt: 1, - }; -} - -describe('findOtherKpFilesOfSupplier', () => { - // Пришёл второй КП Арлан (файл 285) — спросить, заменить ли им KP_01_Arlan_Office_KZT.pdf. - it('находит уже загруженный КП того же поставщика', () => { - const other = findOtherKpFilesOfSupplier({ - columns: [KZT_COLUMN, KALA_COLUMN], - uploadedKpFiles: UPLOADED, - sessionFiles: [], - supplierSpId: 263, - fileSpId: 285, - }); - - expect(other).toEqual([{ spId: 277, name: 'KP_01_Arlan_Office_KZT.pdf' }]); - }); - - it('первый КП поставщика — спрашивать нечего', () => { - const other = findOtherKpFilesOfSupplier({ - columns: [KALA_COLUMN], - uploadedKpFiles: UPLOADED, - sessionFiles: [], - supplierSpId: 263, - fileSpId: 285, - }); - - expect(other).toEqual([]); - }); - - it('тот же файл, обработанный повторно, не считается другим КП', () => { - const other = findOtherKpFilesOfSupplier({ - columns: [KZT_COLUMN], - uploadedKpFiles: UPLOADED, - sessionFiles: [], - supplierSpId: 263, - fileSpId: 277, - }); - - expect(other).toEqual([]); - }); - - it('видит КП того же поставщика, который ещё обрабатывается в этой сессии', () => { - const other = findOtherKpFilesOfSupplier({ - columns: [], - uploadedKpFiles: [], - sessionFiles: [{ spId: 277, name: 'KP_01_Arlan_Office_KZT.pdf', supplierSpId: 263 }], - supplierSpId: 263, - fileSpId: 285, - }); - - expect(other).toEqual([{ spId: 277, name: 'KP_01_Arlan_Office_KZT.pdf' }]); - }); - - it('старая колонка без файла КП тоже считается загруженным КП', () => { - const legacy: Supplier = { id: '263', spId: 263, name: ARLAN, hasOffer: true }; - - const other = findOtherKpFilesOfSupplier({ - columns: [legacy], - uploadedKpFiles: [], - sessionFiles: [], - supplierSpId: 263, - fileSpId: 285, - }); - - expect(other).toEqual([{ spId: undefined, name: ARLAN }]); - }); -}); - -describe('removeReplacedColumns', () => { - it('«Заменить» убирает прежние колонки поставщика, их предложения и файлы', () => { - const usdColumn: Supplier = { id: '263_285', spId: 263, kpFileSpId: 285, name: ARLAN, hasOffer: true }; - - const result = removeReplacedColumns( - { - suppliers: [KZT_COLUMN, KALA_COLUMN, usdColumn], - offers: [makeOffer('263_277'), makeOffer('264_278'), makeOffer('263_285')], - uploadedKpFiles: [...UPLOADED, { spId: 285, name: 'KP_01_Arlan_Office_USD.pdf', supplierName: ARLAN }], - }, - 263, - 285, - ); - - expect(result.suppliers.map(s => s.id)).toEqual(['264_278', '263_285']); - expect(result.offers.map(o => o.supplierId)).toEqual(['264_278', '263_285']); - expect(result.uploadedKpFiles.map(f => f.spId)).toEqual([278, 285]); - }); -}); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/kpReplace.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/kpReplace.ts deleted file mode 100644 index 6c25ad0..0000000 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/kpReplace.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Supplier, SupplierOffer } from '../types/domain'; -import { CTFormData } from '../hooks/useCtForm'; - -// Второй КП того же поставщика: пользователь выбирает, заменить им прежний (исправленный КП, тот же -// файл под другим именем) или добавить отдельным предложением (напр. другая валюта). Сама система -// эти случаи не различает — поставщик определяется только по названию. -export type KpReplaceChoice = 'replace' | 'separate'; - -// Уже загруженный КП поставщика — для текста вопроса. spId нет у старых колонок без файла КП. -export interface ExistingKpFile { - spId?: number; - name: string; -} - -export interface FindOtherKpFilesArgs { - columns: Supplier[]; - uploadedKpFiles: CTFormData['uploadedKpFiles']; - // КП этой сессии, которые ещё обрабатываются: их колонки появятся только после обработки. - sessionFiles: Array<{ spId?: number; name: string; supplierSpId?: number }>; - supplierSpId: number; - fileSpId: number; -} - -// Другие КП того же поставщика в этой СТ (кроме только что обработанного файла). -export function findOtherKpFilesOfSupplier(args: FindOtherKpFilesArgs): ExistingKpFile[] { - const { columns, uploadedKpFiles, sessionFiles, supplierSpId, fileSpId } = args; - const found: ExistingKpFile[] = []; - const seenFileIds = new Set([fileSpId]); - - columns - .filter(c => c.hasOffer && c.spId === supplierSpId) - .forEach(c => { - if (c.kpFileSpId === undefined) { - // Старая колонка без файла КП (данные до появления нескольких КП у поставщика). - found.push({ spId: undefined, name: c.name }); - return; - } - if (seenFileIds.has(c.kpFileSpId)) return; - seenFileIds.add(c.kpFileSpId); - const uploaded = uploadedKpFiles.find(u => u.spId === c.kpFileSpId); - found.push({ spId: c.kpFileSpId, name: uploaded?.name ?? c.name }); - }); - - sessionFiles - .filter(f => f.supplierSpId === supplierSpId && f.spId !== undefined && !seenFileIds.has(f.spId)) - .forEach(f => { - seenFileIds.add(f.spId as number); - found.push({ spId: f.spId, name: f.name }); - }); - - return found; -} - -type ReplaceableData = Pick; - -// «Заменить»: убираем прежние колонки поставщика (все, кроме колонки нового файла КП), их предложения -// и записи в списке «Загруженные КП». Сам файл остаётся в библиотеке — удаляются только данные СТ. -export function removeReplacedColumns( - data: ReplaceableData, - supplierSpId: number, - keepFileSpId: number, -): ReplaceableData { - const isReplaced = (s: Supplier): boolean => - s.hasOffer === true && s.spId === supplierSpId && s.kpFileSpId !== keepFileSpId; - - const replacedColumns = data.suppliers.filter(isReplaced); - const replacedColumnIds = new Set(replacedColumns.map(s => s.id)); - const replacedFileIds = new Set( - replacedColumns.map(s => s.kpFileSpId).filter((id): id is number => id !== undefined), - ); - - return { - suppliers: data.suppliers.filter(s => !isReplaced(s)), - offers: data.offers.filter((o: SupplierOffer) => !replacedColumnIds.has(o.supplierId)), - uploadedKpFiles: data.uploadedKpFiles.filter(f => !replacedFileIds.has(f.spId)), - }; -} diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpProcessing.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpProcessing.ts index 24aeece..20125c4 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpProcessing.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpProcessing.ts @@ -4,8 +4,6 @@ import { CTFormData } from '../hooks/useCtForm'; import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from './kpTypes'; import { buildKpSuppliersAndOffers } from './step2Persist'; import { buildKpOffersToSave } from './kpOfferRecords'; -import { KpReplaceChoice, findOtherKpFilesOfSupplier, removeReplacedColumns } from './kpReplace'; -import { KpReplaceQuestion, useKpReplaceQuestion } from './useKpReplaceQuestion'; let localIdCounter = 0; @@ -16,10 +14,9 @@ export interface UseKpProcessingResult { handleFilesAdded: (files: File[]) => Promise; handleFileRemove: (localId: string) => void; handleCurrencyChange: (localId: string, currency: string) => void; + // Убрать файл из очереди загрузки по SP Id (КП убрали из сравнения): иначе он снова соберётся в колонку. + removeFileBySpId: (fileSpId: number) => void; isProcessing: boolean; - // Вопрос «заменить / добавить отдельно» для второго КП поставщика (undefined — окна нет). - replaceQuestion: KpReplaceQuestion | undefined; - answerReplaceQuestion: (choice: KpReplaceChoice) => void; } // Конвейер обработки КП (Шаг 2): загрузка в библиотеку → извлечение текста (Word) → поллинг Syntex → @@ -36,7 +33,6 @@ export function useKpProcessing( const mountedRef = useRef(true); const formDataRef = useRef(formData); const kpFilesRef = useRef([]); - const replaceQuestion = useKpReplaceQuestion(); useEffect(() => { formDataRef.current = formData; }, [formData]); useEffect(() => { kpFilesRef.current = kpFiles; }, [kpFiles]); @@ -75,50 +71,6 @@ export function useKpProcessing( // eslint-disable-next-line react-hooks/exhaustive-deps }, [kpFiles]); - // Другие КП этого поставщика в СТ — если есть, спрашиваем «заменить или добавить отдельно». - const askReplaceChoice = async ( - localId: string, - fileName: string, - fileSpId: number, - supplierSpId: number, - supplierName: string, - ): Promise => { - const fd = formDataRef.current; - const existing = findOtherKpFilesOfSupplier({ - columns: fd.suppliers ?? [], - uploadedKpFiles: fd.uploadedKpFiles ?? [], - sessionFiles: kpFilesRef.current - .filter(f => f.localId !== localId && f.status !== 'error') - .map(f => ({ spId: f.spId, name: f.file.name, supplierSpId: f.supplierSpId })), - supplierSpId, - fileSpId, - }); - if (existing.length === 0) return 'separate'; - return replaceQuestion.ask({ fileName, supplierName, existing }); - }; - - // «Заменить»: прежние КП поставщика уходят из сравнения — из стейта сразу, из SP (в корзину) после - // записи предложений нового КП. Стейт обновляем до статуса done нового файла: иначе инкрементальный - // persist ниже успеет собрать колонки по ещё не очищенным formData. - const applyReplace = async (supplierSpId: number, keepFileSpId: number): Promise => { - const fd = formDataRef.current; - setKpFiles(prev => prev.filter(f => f.supplierSpId !== supplierSpId || f.spId === keepFileSpId)); - onSave(removeReplacedColumns( - { suppliers: fd.suppliers ?? [], offers: fd.offers ?? [], uploadedKpFiles: fd.uploadedKpFiles ?? [] }, - supplierSpId, - keepFileSpId, - )); - const positionSpIds = [...fd.selectedPositions, ...(fd.step3ExtraPositions ?? [])] - .map(p => p.spId) - .filter((id): id is number => id !== undefined); - try { - await service.recycleOtherKpOffers(supplierSpId, positionSpIds, keepFileSpId); - } catch (err) { - // Не критично: «Сохранить» на Шаге 2/3 перезапишет предложения позиций по текущей таблице. - console.warn('[КП] не удалось убрать предложения прежнего КП:', err); - } - }; - const patchFile = (localId: string, patch: Partial): void => { if (!mountedRef.current) return; setKpFiles(prev => prev.map(f => f.localId === localId ? { ...f, ...patch } : f)); @@ -157,11 +109,9 @@ export function useKpProcessing( // If supplier wasn't set by Syntex, use supplier name from flow result let resolvedSupplierSpId = supplierSpId; - let resolvedSupplierName = kpFilesRef.current.find(f => f.localId === localId)?.supplierName ?? ''; if (!resolvedSupplierSpId) { const supplierName = collectedResults.find(r => r.flowResult.supplier)?.flowResult.supplier; if (supplierName) { - resolvedSupplierName = supplierName; try { resolvedSupplierSpId = await service.saveSupplierWithOffer(supplierName); patchFile(localId, { supplierName, supplierSpId: resolvedSupplierSpId }); @@ -171,11 +121,6 @@ export function useKpProcessing( } } - // Второй КП того же поставщика — ждём выбора пользователя до записи в SP. - const replaceChoice: KpReplaceChoice = resolvedSupplierSpId - ? await askReplaceChoice(localId, fileName, fileSpId, resolvedSupplierSpId, resolvedSupplierName) - : 'separate'; - // Предложения пишем после того, как поставщик определён: Syntex отключён, и поставщика чаще // называет сам поток — внутри цикла по позициям его ещё нет, и записи раньше не создавались вовсе. const offersToSave = buildKpOffersToSave(collectedResults, positions, resolvedSupplierSpId, fileSpId); @@ -186,10 +131,6 @@ export function useKpProcessing( }) )); - if (replaceChoice === 'replace' && resolvedSupplierSpId) { - await applyReplace(resolvedSupplierSpId, fileSpId); - } - // БИН поставщика (для Adata) — берём первый результат из позиций, где он вообще пришёл const supplierBIN = collectedResults.find(r => r.flowResult.supplierBIN)?.flowResult.supplierBIN; if (supplierBIN && resolvedSupplierSpId) { @@ -332,6 +273,10 @@ export function useKpProcessing( setKpFiles(prev => prev.filter(f => f.localId !== localId)); }; + const removeFileBySpId = (fileSpId: number): void => { + setKpFiles(prev => prev.filter(f => f.spId !== fileSpId)); + }; + const handleCurrencyChange = (localId: string, currency: string): void => { setKpFiles(prev => prev.map(f => f.localId === localId ? { ...f, currencyCode: currency } : f)); }; @@ -345,8 +290,7 @@ export function useKpProcessing( handleFilesAdded, handleFileRemove, handleCurrencyChange, + removeFileBySpId, isProcessing, - replaceQuestion: replaceQuestion.current, - answerReplaceQuestion: replaceQuestion.answer, }; } diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpReplaceQuestion.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpReplaceQuestion.ts deleted file mode 100644 index aac510e..0000000 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpReplaceQuestion.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { ExistingKpFile, KpReplaceChoice } from './kpReplace'; - -// Вопрос «заменить или добавить отдельно» по одному обработанному КП. -export interface KpReplaceQuestion { - fileName: string; - supplierName: string; - existing: ExistingKpFile[]; -} - -interface PendingQuestion { - question: KpReplaceQuestion; - resolve: (choice: KpReplaceChoice) => void; -} - -export interface UseKpReplaceQuestionResult { - // Вопрос, который сейчас показан (undefined — окна нет). - current: KpReplaceQuestion | undefined; - ask: (question: KpReplaceQuestion) => Promise; - answer: (choice: KpReplaceChoice) => void; -} - -// Очередь вопросов: несколько КП обрабатываются параллельно, а окно одно — спрашиваем по очереди. -// Обработка КП ждёт ответа (Promise) перед записью предложений в SP. -// Ушли с шага, не ответив — считаем «добавить отдельно»: так ничего не удаляется. -export function useKpReplaceQuestion(): UseKpReplaceQuestionResult { - const queueRef = useRef([]); - const [current, setCurrent] = useState(undefined); - - const showNext = useCallback((): void => { - setCurrent(queueRef.current[0]?.question); - }, []); - - const ask = useCallback((question: KpReplaceQuestion): Promise => { - return new Promise(resolve => { - queueRef.current.push({ question, resolve }); - if (queueRef.current.length === 1) showNext(); - }); - }, [showNext]); - - const answer = useCallback((choice: KpReplaceChoice): void => { - const pending = queueRef.current.shift(); - pending?.resolve(choice); - showNext(); - }, [showNext]); - - useEffect(() => () => { - queueRef.current.forEach(p => p.resolve('separate')); - queueRef.current = []; - }, []); - - return { current, ask, answer }; -} diff --git a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts index e44e9b3..52e8059 100644 --- a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts +++ b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts @@ -25,7 +25,7 @@ import { writeRoute, } from "../../../shared/approval"; import { - findOtherKpOfferIds, + findKpFileOfferIds, findSupersededOfferIds, keepLatestOfferRecords, parseOfferItemIndex, @@ -1619,29 +1619,24 @@ export class CtService { }); } - // «Заменить» прежний КП поставщика новым (Шаг 2): записи поставщика по позициям СТ, кроме записей - // нового файла, уходят в корзину. Вызывается после записи предложений нового КП — при сбое записи - // прежнее предложение останется. - async recycleOtherKpOffers( - supplierSpId: number, - positionSpIds: number[], - keepFileSpId: number, - ): Promise { + // КП убрали из сравнения (Шаг 2, корзина в «Загруженные КП»): записи этого файла по позициям СТ + // уходят в корзину. Сам файл остаётся в библиотеке. Старые записи без offerFileId не найти — + // их перезапишет «Сохранить» по текущей таблице. + async recycleKpFileOffers(fileSpId: number, positionSpIds: number[]): Promise { if (positionSpIds.length === 0) return; const positionFilter = positionSpIds.map((id) => `positionId eq ${id}`).join(" or "); const url = - `${this._li(LISTS.OFFERS)}?$select=Id,supplierId,offerFileId` + - `&$filter=supplierId eq ${supplierSpId} and (${positionFilter})&$top=5000`; + `${this._li(LISTS.OFFERS)}?$select=Id,offerFileId` + + `&$filter=offerFileId eq ${fileSpId} and (${positionFilter})&$top=5000`; const res = await this.http.get(url, SPHttpClient.configurations.v1); - if (!res.ok) throw new Error(`Ошибка чтения прежних предложений: ${res.status}`); + if (!res.ok) throw new Error(`Ошибка чтения предложений КП: ${res.status}`); const data = await res.json(); const records = ((data.value ?? []) as Array>).map((item) => ({ id: Number(item.Id), - supplierSpId: Number(item.supplierId), offerFileSpId: item.offerFileId ? Number(item.offerFileId) : undefined, })); // MERGE на этом списке блокирует ресивер — убираем через recycle(), как в saveOffers. - await this._runChunked(findOtherKpOfferIds(records, supplierSpId, keepFileSpId), 20, async (id) => { + await this._runChunked(findKpFileOfferIds(records, fileSpId), 20, async (id) => { await this.http .post(`${this._liById(LISTS.OFFERS, id)}/recycle()`, SPHttpClient.configurations.v1, {}) .catch(() => undefined /* уже в корзине / не найден — игнорируем */); diff --git a/src/webparts/ctFormsPortal/shared/offers/offerRecords.test.ts b/src/webparts/ctFormsPortal/shared/offers/offerRecords.test.ts index 823e8d3..adac325 100644 --- a/src/webparts/ctFormsPortal/shared/offers/offerRecords.test.ts +++ b/src/webparts/ctFormsPortal/shared/offers/offerRecords.test.ts @@ -1,4 +1,4 @@ -import { IOfferRecordKey, findOtherKpOfferIds, findSupersededOfferIds, findSupplierOfferIds, keepLatestOfferRecords, parseOfferItemIndex } from './offerRecords'; +import { IOfferRecordKey, findKpFileOfferIds, findSupersededOfferIds, findSupplierOfferIds, keepLatestOfferRecords, parseOfferItemIndex } from './offerRecords'; interface ITestRecord extends IOfferRecordKey { price: number; @@ -150,15 +150,15 @@ describe('несколько КП одного поставщика (offerFileSp }); }); -describe('findOtherKpOfferIds («Заменить» прежний КП новым)', () => { - it('возвращает записи других КП поставщика и старые записи без файла', () => { +describe('findKpFileOfferIds (КП убрали из сравнения)', () => { + it('возвращает записи только этого файла КП', () => { const records = [ makeRecord({ id: 10, supplierSpId: 263, offerFileSpId: 277 }), makeRecord({ id: 11, supplierSpId: 263 }), makeRecord({ id: 12, supplierSpId: 263, offerFileSpId: 285 }), - makeRecord({ id: 13, supplierSpId: 264, offerFileSpId: 278 }), + makeRecord({ id: 13, supplierSpId: 264, offerFileSpId: 277 }), ]; - expect(findOtherKpOfferIds(records, 263, 285)).toEqual([10, 11]); + expect(findKpFileOfferIds(records, 277)).toEqual([10, 13]); }); }); diff --git a/src/webparts/ctFormsPortal/shared/offers/offerRecords.ts b/src/webparts/ctFormsPortal/shared/offers/offerRecords.ts index 6a7fc5e..5bc325a 100644 --- a/src/webparts/ctFormsPortal/shared/offers/offerRecords.ts +++ b/src/webparts/ctFormsPortal/shared/offers/offerRecords.ts @@ -46,16 +46,12 @@ export function findSupplierOfferIds( .map(record => record.id); } -// «Заменить» прежний КП поставщика новым: все записи поставщика, кроме записей нового файла КП, -// включая старые записи без файла. -export function findOtherKpOfferIds( - records: Array>, - supplierSpId: number, - keepFileSpId: number, +// Записи одного файла КП — когда КП убирают из сравнения (корзина в «Загруженные КП»). +export function findKpFileOfferIds( + records: Array>, + fileSpId: number, ): number[] { - return records - .filter(record => record.supplierSpId === supplierSpId && record.offerFileSpId !== keepFileSpId) - .map(record => record.id); + return records.filter(record => record.offerFileSpId === fileSpId).map(record => record.id); } // Какие записи вытеснила только что сохранённая: прежние записи того же поставщика и того же файла КП